Import 'mls-rs' crate

Request Document: go/android-rust-importing-crates
For CL Reviewers: go/android3p#cl-review
For Build Team: go/ab-third-party-imports
Bug: http://b/330708876
Test: m libmls_rs

Change-Id: Ib0a891a4d7bf582ebea9ba7a1447ea959e42e0d3
diff --git a/src/group/ciphertext_processor.rs b/src/group/ciphertext_processor.rs
new file mode 100644
index 0000000..bf70f5d
--- /dev/null
+++ b/src/group/ciphertext_processor.rs
@@ -0,0 +1,410 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// Copyright by contributors to this project.
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+
+use self::{
+    message_key::MessageKey,
+    reuse_guard::ReuseGuard,
+    sender_data_key::{SenderData, SenderDataAAD, SenderDataKey},
+};
+
+use super::{
+    epoch::EpochSecrets,
+    framing::{ContentType, FramedContent, Sender, WireFormat},
+    message_signature::AuthenticatedContent,
+    padding::PaddingMode,
+    secret_tree::{KeyType, MessageKeyData},
+    GroupContext,
+};
+use crate::{
+    client::MlsError,
+    tree_kem::node::{LeafIndex, NodeIndex},
+};
+use mls_rs_codec::MlsEncode;
+use mls_rs_core::{crypto::CipherSuiteProvider, error::IntoAnyError};
+use zeroize::Zeroizing;
+
+mod message_key;
+mod reuse_guard;
+mod sender_data_key;
+
+#[cfg(feature = "private_message")]
+use super::framing::{PrivateContentAAD, PrivateMessage, PrivateMessageContent};
+
+#[cfg(test)]
+pub use sender_data_key::test_utils::*;
+
+pub(crate) trait GroupStateProvider {
+    fn group_context(&self) -> &GroupContext;
+    fn self_index(&self) -> LeafIndex;
+    fn epoch_secrets_mut(&mut self) -> &mut EpochSecrets;
+    fn epoch_secrets(&self) -> &EpochSecrets;
+}
+
+pub(crate) struct CiphertextProcessor<'a, GS, CP>
+where
+    GS: GroupStateProvider,
+    CP: CipherSuiteProvider,
+{
+    group_state: &'a mut GS,
+    cipher_suite_provider: CP,
+}
+
+impl<'a, GS, CP> CiphertextProcessor<'a, GS, CP>
+where
+    GS: GroupStateProvider,
+    CP: CipherSuiteProvider,
+{
+    pub fn new(
+        group_state: &'a mut GS,
+        cipher_suite_provider: CP,
+    ) -> CiphertextProcessor<'a, GS, CP> {
+        Self {
+            group_state,
+            cipher_suite_provider,
+        }
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn next_encryption_key(
+        &mut self,
+        key_type: KeyType,
+    ) -> Result<MessageKeyData, MlsError> {
+        let self_index = NodeIndex::from(self.group_state.self_index());
+
+        self.group_state
+            .epoch_secrets_mut()
+            .secret_tree
+            .next_message_key(&self.cipher_suite_provider, self_index, key_type)
+            .await
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn decryption_key(
+        &mut self,
+        sender: LeafIndex,
+        key_type: KeyType,
+        generation: u32,
+    ) -> Result<MessageKeyData, MlsError> {
+        let sender = NodeIndex::from(sender);
+
+        self.group_state
+            .epoch_secrets_mut()
+            .secret_tree
+            .message_key_generation(&self.cipher_suite_provider, sender, key_type, generation)
+            .await
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn seal(
+        &mut self,
+        auth_content: AuthenticatedContent,
+        padding: PaddingMode,
+    ) -> Result<PrivateMessage, MlsError> {
+        if Sender::Member(*self.group_state.self_index()) != auth_content.content.sender {
+            return Err(MlsError::InvalidSender);
+        }
+
+        let content_type = ContentType::from(&auth_content.content.content);
+        let authenticated_data = auth_content.content.authenticated_data;
+
+        // Build a ciphertext content using the plaintext content and signature
+        let private_content = PrivateMessageContent {
+            content: auth_content.content.content,
+            auth: auth_content.auth,
+        };
+
+        // Build ciphertext aad using the plaintext message
+        let aad = PrivateContentAAD {
+            group_id: auth_content.content.group_id,
+            epoch: auth_content.content.epoch,
+            content_type,
+            authenticated_data: authenticated_data.clone(),
+        };
+
+        // Generate a 4 byte reuse guard
+        let reuse_guard = ReuseGuard::random(&self.cipher_suite_provider)
+            .map_err(|e| MlsError::CryptoProviderError(e.into_any_error()))?;
+
+        // Grab an encryption key from the current epoch's key schedule
+        let key_type = match &content_type {
+            ContentType::Application => KeyType::Application,
+            _ => KeyType::Handshake,
+        };
+
+        let mut serialized_private_content = private_content.mls_encode_to_vec()?;
+
+        // Apply padding to private content based on the current padding mode.
+        serialized_private_content.resize(padding.padded_size(serialized_private_content.len()), 0);
+
+        let serialized_private_content = Zeroizing::new(serialized_private_content);
+
+        // Encrypt the ciphertext content using the encryption key and a nonce that is
+        // reuse safe by xor the reuse guard with the first 4 bytes
+        let self_index = self.group_state.self_index();
+
+        let key_data = self.next_encryption_key(key_type).await?;
+        let generation = key_data.generation;
+
+        let ciphertext = MessageKey::new(key_data)
+            .encrypt(
+                &self.cipher_suite_provider,
+                &serialized_private_content,
+                &aad.mls_encode_to_vec()?,
+                &reuse_guard,
+            )
+            .await
+            .map_err(|e| MlsError::CryptoProviderError(e.into_any_error()))?;
+
+        // Construct an mls sender data struct using the plaintext sender info, the generation
+        // of the key schedule encryption key, and the reuse guard used to encrypt ciphertext
+        let sender_data = SenderData {
+            sender: self_index,
+            generation,
+            reuse_guard,
+        };
+
+        let sender_data_aad = SenderDataAAD {
+            group_id: self.group_state.group_context().group_id.clone(),
+            epoch: self.group_state.group_context().epoch,
+            content_type,
+        };
+
+        // Encrypt the sender data with the derived sender_key and sender_nonce from the current
+        // epoch's key schedule
+        let sender_data_key = SenderDataKey::new(
+            &self.group_state.epoch_secrets().sender_data_secret,
+            &ciphertext,
+            &self.cipher_suite_provider,
+        )
+        .await?;
+
+        let encrypted_sender_data = sender_data_key.seal(&sender_data, &sender_data_aad).await?;
+
+        Ok(PrivateMessage {
+            group_id: self.group_state.group_context().group_id.clone(),
+            epoch: self.group_state.group_context().epoch,
+            content_type,
+            authenticated_data,
+            encrypted_sender_data,
+            ciphertext,
+        })
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn open(
+        &mut self,
+        ciphertext: &PrivateMessage,
+    ) -> Result<AuthenticatedContent, MlsError> {
+        // Decrypt the sender data with the derived sender_key and sender_nonce from the message
+        // epoch's key schedule
+        let sender_data_aad = SenderDataAAD {
+            group_id: self.group_state.group_context().group_id.clone(),
+            epoch: self.group_state.group_context().epoch,
+            content_type: ciphertext.content_type,
+        };
+
+        let sender_data_key = SenderDataKey::new(
+            &self.group_state.epoch_secrets().sender_data_secret,
+            &ciphertext.ciphertext,
+            &self.cipher_suite_provider,
+        )
+        .await?;
+
+        let sender_data = sender_data_key
+            .open(&ciphertext.encrypted_sender_data, &sender_data_aad)
+            .await?;
+
+        if self.group_state.self_index() == sender_data.sender {
+            return Err(MlsError::CantProcessMessageFromSelf);
+        }
+
+        // Grab a decryption key from the message epoch's key schedule
+        let key_type = match &ciphertext.content_type {
+            ContentType::Application => KeyType::Application,
+            _ => KeyType::Handshake,
+        };
+
+        // Decrypt the content of the message using the grabbed key
+        let key = self
+            .decryption_key(sender_data.sender, key_type, sender_data.generation)
+            .await?;
+
+        let sender = Sender::Member(*sender_data.sender);
+
+        let decrypted_content = MessageKey::new(key)
+            .decrypt(
+                &self.cipher_suite_provider,
+                &ciphertext.ciphertext,
+                &PrivateContentAAD::from(ciphertext).mls_encode_to_vec()?,
+                &sender_data.reuse_guard,
+            )
+            .await
+            .map_err(|e| MlsError::CryptoProviderError(e.into_any_error()))?;
+
+        let ciphertext_content =
+            PrivateMessageContent::mls_decode(&mut &**decrypted_content, ciphertext.content_type)?;
+
+        // Build the MLS plaintext object and process it
+        let auth_content = AuthenticatedContent {
+            wire_format: WireFormat::PrivateMessage,
+            content: FramedContent {
+                group_id: ciphertext.group_id.clone(),
+                epoch: ciphertext.epoch,
+                sender,
+                authenticated_data: ciphertext.authenticated_data.clone(),
+                content: ciphertext_content.content,
+            },
+            auth: ciphertext_content.auth,
+        };
+
+        Ok(auth_content)
+    }
+}
+
+#[cfg(test)]
+mod test {
+    use crate::{
+        cipher_suite::CipherSuite,
+        client::test_utils::{TEST_CIPHER_SUITE, TEST_PROTOCOL_VERSION},
+        crypto::{
+            test_utils::{test_cipher_suite_provider, TestCryptoProvider},
+            CipherSuiteProvider,
+        },
+        group::{
+            framing::{ApplicationData, Content, Sender, WireFormat},
+            message_signature::AuthenticatedContent,
+            padding::PaddingMode,
+            test_utils::{random_bytes, test_group, TestGroup},
+        },
+        tree_kem::node::LeafIndex,
+    };
+
+    use super::{CiphertextProcessor, GroupStateProvider, MlsError};
+
+    use alloc::vec;
+    use assert_matches::assert_matches;
+
+    struct TestData {
+        group: TestGroup,
+        content: AuthenticatedContent,
+    }
+
+    fn test_processor(
+        group: &mut TestGroup,
+        cipher_suite: CipherSuite,
+    ) -> CiphertextProcessor<'_, impl GroupStateProvider, impl CipherSuiteProvider> {
+        CiphertextProcessor::new(&mut group.group, test_cipher_suite_provider(cipher_suite))
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn test_data(cipher_suite: CipherSuite) -> TestData {
+        let provider = test_cipher_suite_provider(cipher_suite);
+
+        let group = test_group(TEST_PROTOCOL_VERSION, cipher_suite).await;
+
+        let content = AuthenticatedContent::new_signed(
+            &provider,
+            group.group.context(),
+            Sender::Member(0),
+            Content::Application(ApplicationData::from(b"test".to_vec())),
+            &group.group.signer,
+            WireFormat::PrivateMessage,
+            vec![],
+        )
+        .await
+        .unwrap();
+
+        TestData { group, content }
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_encrypt_decrypt() {
+        for cipher_suite in TestCryptoProvider::all_supported_cipher_suites() {
+            let mut test_data = test_data(cipher_suite).await;
+            let mut receiver_group = test_data.group.clone();
+
+            let mut ciphertext_processor = test_processor(&mut test_data.group, cipher_suite);
+
+            let ciphertext = ciphertext_processor
+                .seal(test_data.content.clone(), PaddingMode::StepFunction)
+                .await
+                .unwrap();
+
+            receiver_group.group.private_tree.self_index = LeafIndex::new(1);
+
+            let mut receiver_processor = test_processor(&mut receiver_group, cipher_suite);
+
+            let decrypted = receiver_processor.open(&ciphertext).await.unwrap();
+
+            assert_eq!(decrypted, test_data.content);
+        }
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_padding_use() {
+        let mut test_data = test_data(TEST_CIPHER_SUITE).await;
+        let mut ciphertext_processor = test_processor(&mut test_data.group, TEST_CIPHER_SUITE);
+
+        let ciphertext_step = ciphertext_processor
+            .seal(test_data.content.clone(), PaddingMode::StepFunction)
+            .await
+            .unwrap();
+
+        let ciphertext_no_pad = ciphertext_processor
+            .seal(test_data.content.clone(), PaddingMode::None)
+            .await
+            .unwrap();
+
+        assert!(ciphertext_step.ciphertext.len() > ciphertext_no_pad.ciphertext.len());
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_invalid_sender() {
+        let mut test_data = test_data(TEST_CIPHER_SUITE).await;
+        test_data.content.content.sender = Sender::Member(3);
+
+        let mut ciphertext_processor = test_processor(&mut test_data.group, TEST_CIPHER_SUITE);
+
+        let res = ciphertext_processor
+            .seal(test_data.content, PaddingMode::None)
+            .await;
+
+        assert_matches!(res, Err(MlsError::InvalidSender))
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_cant_process_from_self() {
+        let mut test_data = test_data(TEST_CIPHER_SUITE).await;
+
+        let mut ciphertext_processor = test_processor(&mut test_data.group, TEST_CIPHER_SUITE);
+
+        let ciphertext = ciphertext_processor
+            .seal(test_data.content, PaddingMode::None)
+            .await
+            .unwrap();
+
+        let res = ciphertext_processor.open(&ciphertext).await;
+
+        assert_matches!(res, Err(MlsError::CantProcessMessageFromSelf))
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_decryption_error() {
+        let mut test_data = test_data(TEST_CIPHER_SUITE).await;
+        let mut receiver_group = test_data.group.clone();
+        let mut ciphertext_processor = test_processor(&mut test_data.group, TEST_CIPHER_SUITE);
+
+        let mut ciphertext = ciphertext_processor
+            .seal(test_data.content.clone(), PaddingMode::StepFunction)
+            .await
+            .unwrap();
+
+        ciphertext.ciphertext = random_bytes(ciphertext.ciphertext.len());
+        receiver_group.group.private_tree.self_index = LeafIndex::new(1);
+
+        let res = ciphertext_processor.open(&ciphertext).await;
+
+        assert!(res.is_err());
+    }
+}
diff --git a/src/group/ciphertext_processor/message_key.rs b/src/group/ciphertext_processor/message_key.rs
new file mode 100644
index 0000000..256db7d
--- /dev/null
+++ b/src/group/ciphertext_processor/message_key.rs
@@ -0,0 +1,57 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// Copyright by contributors to this project.
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+
+use alloc::vec::Vec;
+use zeroize::Zeroizing;
+
+use crate::{crypto::CipherSuiteProvider, group::secret_tree::MessageKeyData};
+
+use super::reuse_guard::ReuseGuard;
+
+#[derive(Debug, PartialEq, Eq)]
+pub struct MessageKey(MessageKeyData);
+
+impl MessageKey {
+    pub(crate) fn new(key: MessageKeyData) -> MessageKey {
+        MessageKey(key)
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(crate) async fn encrypt<P: CipherSuiteProvider>(
+        &self,
+        provider: &P,
+        data: &[u8],
+        aad: &[u8],
+        reuse_guard: &ReuseGuard,
+    ) -> Result<Vec<u8>, P::Error> {
+        provider
+            .aead_seal(
+                &self.0.key,
+                data,
+                Some(aad),
+                &reuse_guard.apply(&self.0.nonce),
+            )
+            .await
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(crate) async fn decrypt<P: CipherSuiteProvider>(
+        &self,
+        provider: &P,
+        data: &[u8],
+        aad: &[u8],
+        reuse_guard: &ReuseGuard,
+    ) -> Result<Zeroizing<Vec<u8>>, P::Error> {
+        provider
+            .aead_open(
+                &self.0.key,
+                data,
+                Some(aad),
+                &reuse_guard.apply(&self.0.nonce),
+            )
+            .await
+    }
+}
+
+// TODO: Write test vectors
diff --git a/src/group/ciphertext_processor/reuse_guard.rs b/src/group/ciphertext_processor/reuse_guard.rs
new file mode 100644
index 0000000..10e1db1
--- /dev/null
+++ b/src/group/ciphertext_processor/reuse_guard.rs
@@ -0,0 +1,133 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// Copyright by contributors to this project.
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+
+use alloc::vec::Vec;
+use mls_rs_codec::{MlsDecode, MlsEncode, MlsSize};
+
+use crate::CipherSuiteProvider;
+
+const REUSE_GUARD_SIZE: usize = 4;
+
+#[derive(Clone, Debug, PartialEq, Eq, MlsSize, MlsEncode, MlsDecode)]
+pub(crate) struct ReuseGuard([u8; REUSE_GUARD_SIZE]);
+
+impl From<[u8; REUSE_GUARD_SIZE]> for ReuseGuard {
+    fn from(value: [u8; REUSE_GUARD_SIZE]) -> Self {
+        ReuseGuard(value)
+    }
+}
+
+impl From<ReuseGuard> for [u8; REUSE_GUARD_SIZE] {
+    fn from(value: ReuseGuard) -> Self {
+        value.0
+    }
+}
+
+impl AsRef<[u8]> for ReuseGuard {
+    fn as_ref(&self) -> &[u8] {
+        &self.0
+    }
+}
+
+impl ReuseGuard {
+    pub(crate) fn random<P: CipherSuiteProvider>(provider: &P) -> Result<Self, P::Error> {
+        let mut data = [0u8; REUSE_GUARD_SIZE];
+        provider.random_bytes(&mut data).map(|_| ReuseGuard(data))
+    }
+
+    pub(crate) fn apply(&self, nonce: &[u8]) -> Vec<u8> {
+        let mut new_nonce = nonce.to_vec();
+
+        new_nonce
+            .iter_mut()
+            .zip(self.as_ref().iter())
+            .for_each(|(nonce_byte, guard_byte)| *nonce_byte ^= guard_byte);
+
+        new_nonce
+    }
+}
+
+#[cfg(test)]
+mod test_utils {
+    use alloc::vec::Vec;
+
+    use super::{ReuseGuard, REUSE_GUARD_SIZE};
+
+    impl ReuseGuard {
+        pub fn new(guard: Vec<u8>) -> Self {
+            let mut data = [0u8; REUSE_GUARD_SIZE];
+            data.copy_from_slice(&guard);
+            Self(data)
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use alloc::vec::Vec;
+    use mls_rs_core::crypto::CipherSuiteProvider;
+
+    use crate::{
+        client::test_utils::TEST_CIPHER_SUITE, crypto::test_utils::test_cipher_suite_provider,
+    };
+
+    use super::{ReuseGuard, REUSE_GUARD_SIZE};
+
+    #[test]
+    fn test_random_generation() {
+        let test_guard =
+            ReuseGuard::random(&test_cipher_suite_provider(TEST_CIPHER_SUITE)).unwrap();
+
+        (0..1000).for_each(|_| {
+            let next = ReuseGuard::random(&test_cipher_suite_provider(TEST_CIPHER_SUITE)).unwrap();
+            assert_ne!(next, test_guard);
+        })
+    }
+
+    #[derive(Debug, serde::Serialize, serde::Deserialize)]
+    struct TestCase {
+        nonce: Vec<u8>,
+        guard: [u8; REUSE_GUARD_SIZE],
+        result: Vec<u8>,
+    }
+
+    #[cfg_attr(coverage_nightly, coverage(off))]
+    fn generate_reuse_guard_test_cases() -> Vec<TestCase> {
+        let provider = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+
+        [16, 32]
+            .into_iter()
+            .map(
+                #[cfg_attr(coverage_nightly, coverage(off))]
+                |len| {
+                    let nonce = provider.random_bytes_vec(len).unwrap();
+                    let guard = ReuseGuard::random(&provider).unwrap();
+
+                    let result = guard.apply(&nonce);
+
+                    TestCase {
+                        nonce,
+                        guard: guard.into(),
+                        result,
+                    }
+                },
+            )
+            .collect()
+    }
+
+    fn load_test_cases() -> Vec<TestCase> {
+        load_test_case_json!(reuse_guard, generate_reuse_guard_test_cases())
+    }
+
+    #[test]
+    fn test_reuse_guard() {
+        let test_cases = load_test_cases();
+
+        for case in test_cases {
+            let guard = ReuseGuard::from(case.guard);
+            let result = guard.apply(&case.nonce);
+            assert_eq!(result, case.result);
+        }
+    }
+}
diff --git a/src/group/ciphertext_processor/sender_data_key.rs b/src/group/ciphertext_processor/sender_data_key.rs
new file mode 100644
index 0000000..983920a
--- /dev/null
+++ b/src/group/ciphertext_processor/sender_data_key.rs
@@ -0,0 +1,360 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// Copyright by contributors to this project.
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+
+use alloc::vec::Vec;
+use core::fmt::{self, Debug};
+use mls_rs_codec::{MlsDecode, MlsEncode, MlsSize};
+use mls_rs_core::error::IntoAnyError;
+use zeroize::Zeroizing;
+
+use crate::{
+    client::MlsError,
+    crypto::CipherSuiteProvider,
+    group::{epoch::SenderDataSecret, framing::ContentType, key_schedule::kdf_expand_with_label},
+    tree_kem::node::LeafIndex,
+};
+
+use super::ReuseGuard;
+
+#[derive(Clone, Debug, PartialEq, Eq, MlsSize, MlsEncode, MlsDecode)]
+pub(crate) struct SenderData {
+    pub sender: LeafIndex,
+    pub generation: u32,
+    pub reuse_guard: ReuseGuard,
+}
+
+#[derive(Clone, PartialEq, Eq, MlsSize, MlsEncode, MlsDecode)]
+pub(crate) struct SenderDataAAD {
+    #[mls_codec(with = "mls_rs_codec::byte_vec")]
+    pub group_id: Vec<u8>,
+    pub epoch: u64,
+    pub content_type: ContentType,
+}
+
+impl Debug for SenderDataAAD {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.debug_struct("SenderDataAAD")
+            .field(
+                "group_id",
+                &mls_rs_core::debug::pretty_group_id(&self.group_id),
+            )
+            .field("epoch", &self.epoch)
+            .field("content_type", &self.content_type)
+            .finish()
+    }
+}
+
+pub(crate) struct SenderDataKey<'a, CP: CipherSuiteProvider> {
+    pub(crate) key: Zeroizing<Vec<u8>>,
+    pub(crate) nonce: Zeroizing<Vec<u8>>,
+    cipher_suite_provider: &'a CP,
+}
+
+impl<CP: CipherSuiteProvider + Debug> Debug for SenderDataKey<'_, CP> {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.debug_struct("SenderDataKey")
+            .field("key", &mls_rs_core::debug::pretty_bytes(&self.key))
+            .field("nonce", &mls_rs_core::debug::pretty_bytes(&self.nonce))
+            .field("cipher_suite_provider", self.cipher_suite_provider)
+            .finish()
+    }
+}
+
+impl<'a, CP: CipherSuiteProvider> SenderDataKey<'a, CP> {
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(super) async fn new(
+        sender_data_secret: &SenderDataSecret,
+        ciphertext: &[u8],
+        cipher_suite_provider: &'a CP,
+    ) -> Result<SenderDataKey<'a, CP>, MlsError> {
+        // Sample the first extract_size bytes of the ciphertext, and if it is shorter, just use
+        // the ciphertext itself
+        let extract_size = cipher_suite_provider.kdf_extract_size();
+        let ciphertext_sample = ciphertext.get(0..extract_size).unwrap_or(ciphertext);
+
+        // Generate a sender data key and nonce using the sender_data_secret from the current
+        // epoch's key schedule
+        let key = kdf_expand_with_label(
+            cipher_suite_provider,
+            sender_data_secret,
+            b"key",
+            ciphertext_sample,
+            Some(cipher_suite_provider.aead_key_size()),
+        )
+        .await?;
+
+        let nonce = kdf_expand_with_label(
+            cipher_suite_provider,
+            sender_data_secret,
+            b"nonce",
+            ciphertext_sample,
+            Some(cipher_suite_provider.aead_nonce_size()),
+        )
+        .await?;
+
+        Ok(Self {
+            key,
+            nonce,
+            cipher_suite_provider,
+        })
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(crate) async fn seal(
+        &self,
+        sender_data: &SenderData,
+        aad: &SenderDataAAD,
+    ) -> Result<Vec<u8>, MlsError> {
+        self.cipher_suite_provider
+            .aead_seal(
+                &self.key,
+                &sender_data.mls_encode_to_vec()?,
+                Some(&aad.mls_encode_to_vec()?),
+                &self.nonce,
+            )
+            .await
+            .map_err(|e| MlsError::CryptoProviderError(e.into_any_error()))
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(crate) async fn open(
+        &self,
+        sender_data: &[u8],
+        aad: &SenderDataAAD,
+    ) -> Result<SenderData, MlsError> {
+        self.cipher_suite_provider
+            .aead_open(
+                &self.key,
+                sender_data,
+                Some(&aad.mls_encode_to_vec()?),
+                &self.nonce,
+            )
+            .await
+            .map_err(|e| MlsError::CryptoProviderError(e.into_any_error()))
+            .and_then(|data| SenderData::mls_decode(&mut &**data).map_err(From::from))
+    }
+}
+
+#[cfg(test)]
+pub(crate) mod test_utils {
+    use alloc::vec::Vec;
+    use mls_rs_core::crypto::CipherSuiteProvider;
+
+    use super::SenderDataKey;
+
+    #[derive(Debug, serde::Serialize, serde::Deserialize)]
+    pub struct InteropSenderData {
+        #[serde(with = "hex::serde")]
+        pub sender_data_secret: Vec<u8>,
+        #[serde(with = "hex::serde")]
+        pub ciphertext: Vec<u8>,
+        #[serde(with = "hex::serde")]
+        pub key: Vec<u8>,
+        #[serde(with = "hex::serde")]
+        pub nonce: Vec<u8>,
+    }
+
+    impl InteropSenderData {
+        #[cfg(not(mls_build_async))]
+        #[cfg_attr(coverage_nightly, coverage(off))]
+        pub(crate) fn new<P: CipherSuiteProvider>(cs: &P) -> Self {
+            let secret = cs.random_bytes_vec(cs.kdf_extract_size()).unwrap().into();
+            let ciphertext = cs.random_bytes_vec(77).unwrap();
+            let key = SenderDataKey::new(&secret, &ciphertext, cs).unwrap();
+            let secret = (*secret).clone();
+
+            Self {
+                ciphertext,
+                key: key.key.to_vec(),
+                nonce: key.nonce.to_vec(),
+                sender_data_secret: secret,
+            }
+        }
+
+        #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+        pub async fn verify<P: CipherSuiteProvider>(&self, cs: &P) {
+            let secret = self.sender_data_secret.clone().into();
+
+            let key = SenderDataKey::new(&secret, &self.ciphertext, cs)
+                .await
+                .unwrap();
+
+            assert_eq!(key.key.to_vec(), self.key, "sender data key mismatch");
+            assert_eq!(key.nonce.to_vec(), self.nonce, "sender data nonce mismatch");
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+
+    use alloc::vec::Vec;
+    #[cfg(target_arch = "wasm32")]
+    use wasm_bindgen_test::wasm_bindgen_test as test;
+
+    use crate::{
+        crypto::test_utils::try_test_cipher_suite_provider,
+        group::{ciphertext_processor::reuse_guard::ReuseGuard, framing::ContentType},
+        tree_kem::node::LeafIndex,
+    };
+
+    use super::{SenderData, SenderDataAAD, SenderDataKey};
+
+    #[cfg(not(mls_build_async))]
+    use crate::{
+        cipher_suite::CipherSuite, crypto::test_utils::test_cipher_suite_provider,
+        group::test_utils::random_bytes, CipherSuiteProvider,
+    };
+
+    #[derive(serde::Deserialize, serde::Serialize)]
+    struct TestCase {
+        cipher_suite: u16,
+        #[serde(with = "hex::serde")]
+        secret: Vec<u8>,
+        #[serde(with = "hex::serde")]
+        ciphertext_bytes: Vec<u8>,
+        #[serde(with = "hex::serde")]
+        expected_key: Vec<u8>,
+        #[serde(with = "hex::serde")]
+        expected_nonce: Vec<u8>,
+        sender_data: TestSenderData,
+        sender_data_aad: TestSenderDataAAD,
+        #[serde(with = "hex::serde")]
+        expected_ciphertext: Vec<u8>,
+    }
+
+    #[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
+    struct TestSenderData {
+        sender: u32,
+        generation: u32,
+        #[serde(with = "hex::serde")]
+        reuse_guard: Vec<u8>,
+    }
+
+    impl From<TestSenderData> for SenderData {
+        fn from(value: TestSenderData) -> Self {
+            let reuse_guard = ReuseGuard::new(value.reuse_guard);
+
+            Self {
+                sender: LeafIndex(value.sender),
+                generation: value.generation,
+                reuse_guard,
+            }
+        }
+    }
+
+    #[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
+    struct TestSenderDataAAD {
+        epoch: u64,
+        #[serde(with = "hex::serde")]
+        group_id: Vec<u8>,
+    }
+
+    impl From<TestSenderDataAAD> for SenderDataAAD {
+        fn from(value: TestSenderDataAAD) -> Self {
+            Self {
+                epoch: value.epoch,
+                group_id: value.group_id,
+                content_type: ContentType::Application,
+            }
+        }
+    }
+
+    #[cfg(not(mls_build_async))]
+    #[cfg_attr(coverage_nightly, coverage(off))]
+    fn generate_test_vector() -> Vec<TestCase> {
+        let test_cases = CipherSuite::all().map(test_cipher_suite_provider).map(
+            #[cfg_attr(coverage_nightly, coverage(off))]
+            |provider| {
+                let ext_size = provider.kdf_extract_size();
+                let secret = random_bytes(ext_size).into();
+                let ciphertext_sizes = [ext_size - 5, ext_size, ext_size + 5];
+
+                let sender_data = TestSenderData {
+                    sender: 0,
+                    generation: 13,
+                    reuse_guard: random_bytes(4),
+                };
+
+                let sender_data_aad = TestSenderDataAAD {
+                    group_id: b"group".to_vec(),
+                    epoch: 42,
+                };
+
+                ciphertext_sizes.into_iter().map(
+                    #[cfg_attr(coverage_nightly, coverage(off))]
+                    move |ciphertext_size| {
+                        let ciphertext_bytes = random_bytes(ciphertext_size);
+
+                        let sender_data_key =
+                            SenderDataKey::new(&secret, &ciphertext_bytes, &provider).unwrap();
+
+                        let expected_ciphertext = sender_data_key
+                            .seal(&sender_data.clone().into(), &sender_data_aad.clone().into())
+                            .unwrap();
+
+                        TestCase {
+                            cipher_suite: provider.cipher_suite().into(),
+                            secret: secret.to_vec(),
+                            ciphertext_bytes,
+                            expected_key: sender_data_key.key.to_vec(),
+                            expected_nonce: sender_data_key.nonce.to_vec(),
+                            sender_data: sender_data.clone(),
+                            sender_data_aad: sender_data_aad.clone(),
+                            expected_ciphertext,
+                        }
+                    },
+                )
+            },
+        );
+
+        test_cases.flatten().collect()
+    }
+
+    #[cfg(mls_build_async)]
+    fn generate_test_vector() -> Vec<TestCase> {
+        panic!("Tests cannot be generated in async mode");
+    }
+
+    fn load_test_cases() -> Vec<TestCase> {
+        load_test_case_json!(sender_data_key_test_vector, generate_test_vector())
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn sender_data_key_test_vector() {
+        for test_case in load_test_cases() {
+            let Some(provider) = try_test_cipher_suite_provider(test_case.cipher_suite) else {
+                continue;
+            };
+
+            let sender_data_key = SenderDataKey::new(
+                &test_case.secret.into(),
+                &test_case.ciphertext_bytes,
+                &provider,
+            )
+            .await
+            .unwrap();
+
+            assert_eq!(sender_data_key.key.to_vec(), test_case.expected_key);
+            assert_eq!(sender_data_key.nonce.to_vec(), test_case.expected_nonce);
+
+            let sender_data = test_case.sender_data.into();
+            let sender_data_aad = test_case.sender_data_aad.into();
+
+            let ciphertext = sender_data_key
+                .seal(&sender_data, &sender_data_aad)
+                .await
+                .unwrap();
+
+            assert_eq!(ciphertext, test_case.expected_ciphertext);
+
+            let plaintext = sender_data_key
+                .open(&ciphertext, &sender_data_aad)
+                .await
+                .unwrap();
+
+            assert_eq!(plaintext, sender_data);
+        }
+    }
+}
diff --git a/src/group/commit.rs b/src/group/commit.rs
new file mode 100644
index 0000000..c201057
--- /dev/null
+++ b/src/group/commit.rs
@@ -0,0 +1,1601 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// Copyright by contributors to this project.
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+
+use alloc::vec;
+use alloc::vec::Vec;
+use core::fmt::{self, Debug};
+use mls_rs_codec::{MlsDecode, MlsEncode, MlsSize};
+use mls_rs_core::{
+    crypto::{CipherSuiteProvider, SignatureSecretKey},
+    error::IntoAnyError,
+};
+
+use crate::{
+    cipher_suite::CipherSuite,
+    client::MlsError,
+    client_config::ClientConfig,
+    extension::RatchetTreeExt,
+    identity::SigningIdentity,
+    protocol_version::ProtocolVersion,
+    signer::Signable,
+    tree_kem::{
+        kem::TreeKem, node::LeafIndex, path_secret::PathSecret, TreeKemPrivate, UpdatePath,
+    },
+    ExtensionList, MlsRules,
+};
+
+#[cfg(all(not(mls_build_async), feature = "rayon"))]
+use {crate::iter::ParallelIteratorExt, rayon::prelude::*};
+
+use crate::tree_kem::leaf_node::LeafNode;
+
+#[cfg(not(feature = "private_message"))]
+use crate::WireFormat;
+
+#[cfg(feature = "psk")]
+use crate::{
+    group::{JustPreSharedKeyID, PskGroupId, ResumptionPSKUsage, ResumptionPsk},
+    psk::ExternalPskId,
+};
+
+use super::{
+    confirmation_tag::ConfirmationTag,
+    framing::{Content, MlsMessage, MlsMessagePayload, Sender},
+    key_schedule::{KeySchedule, WelcomeSecret},
+    message_processor::{path_update_required, MessageProcessor},
+    message_signature::AuthenticatedContent,
+    mls_rules::CommitDirection,
+    proposal::{Proposal, ProposalOrRef},
+    ConfirmedTranscriptHash, EncryptedGroupSecrets, ExportedTree, Group, GroupContext, GroupInfo,
+    Welcome,
+};
+
+#[cfg(not(feature = "by_ref_proposal"))]
+use super::proposal_cache::prepare_commit;
+
+#[cfg(feature = "custom_proposal")]
+use super::proposal::CustomProposal;
+
+#[derive(Clone, Debug, PartialEq, MlsSize, MlsEncode, MlsDecode)]
+#[cfg_attr(feature = "arbitrary", derive(mls_rs_core::arbitrary::Arbitrary))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+pub(crate) struct Commit {
+    pub proposals: Vec<ProposalOrRef>,
+    pub path: Option<UpdatePath>,
+}
+
+#[derive(Clone, PartialEq, Debug, MlsEncode, MlsDecode, MlsSize)]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+pub(super) struct CommitGeneration {
+    pub content: AuthenticatedContent,
+    pub pending_private_tree: TreeKemPrivate,
+    pub pending_commit_secret: PathSecret,
+    pub commit_message_hash: CommitHash,
+}
+
+#[derive(Clone, PartialEq, MlsEncode, MlsDecode, MlsSize)]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+pub(crate) struct CommitHash(
+    #[mls_codec(with = "mls_rs_codec::byte_vec")]
+    #[cfg_attr(feature = "serde", serde(with = "mls_rs_core::vec_serde"))]
+    Vec<u8>,
+);
+
+impl Debug for CommitHash {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        mls_rs_core::debug::pretty_bytes(&self.0)
+            .named("CommitHash")
+            .fmt(f)
+    }
+}
+
+impl CommitHash {
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(crate) async fn compute<CS: CipherSuiteProvider>(
+        cs: &CS,
+        commit: &MlsMessage,
+    ) -> Result<Self, MlsError> {
+        cs.hash(&commit.mls_encode_to_vec()?)
+            .await
+            .map_err(|e| MlsError::CryptoProviderError(e.into_any_error()))
+            .map(Self)
+    }
+}
+
+#[cfg_attr(
+    all(feature = "ffi", not(test)),
+    safer_ffi_gen::ffi_type(clone, opaque)
+)]
+#[derive(Clone, Debug)]
+#[non_exhaustive]
+/// Result of MLS commit operation using
+/// [`Group::commit`](crate::group::Group::commit) or
+/// [`CommitBuilder::build`](CommitBuilder::build).
+pub struct CommitOutput {
+    /// Commit message to send to other group members.
+    pub commit_message: MlsMessage,
+    /// Welcome messages to send to new group members. If the commit does not add members,
+    /// this list is empty. Otherwise, if [`MlsRules::commit_options`] returns `single_welcome_message`
+    /// set to true, then this list contains a single message sent to all members. Else, the list
+    /// contains one message for each added member. Recipients of each message can be identified using
+    /// [`MlsMessage::key_package_reference`] of their key packages and
+    /// [`MlsMessage::welcome_key_package_references`].
+    pub welcome_messages: Vec<MlsMessage>,
+    /// Ratchet tree that can be sent out of band if
+    /// `ratchet_tree_extension` is not used according to
+    /// [`MlsRules::commit_options`].
+    pub ratchet_tree: Option<ExportedTree<'static>>,
+    /// A group info that can be provided to new members in order to enable external commit
+    /// functionality. This value is set if [`MlsRules::commit_options`] returns
+    /// `allow_external_commit` set to true.
+    pub external_commit_group_info: Option<MlsMessage>,
+    /// Proposals that were received in the prior epoch but not included in the following commit.
+    #[cfg(feature = "by_ref_proposal")]
+    pub unused_proposals: Vec<crate::mls_rules::ProposalInfo<Proposal>>,
+}
+
+#[cfg_attr(all(feature = "ffi", not(test)), ::safer_ffi_gen::safer_ffi_gen)]
+impl CommitOutput {
+    /// Commit message to send to other group members.
+    #[cfg(feature = "ffi")]
+    pub fn commit_message(&self) -> &MlsMessage {
+        &self.commit_message
+    }
+
+    /// Welcome message to send to new group members.
+    #[cfg(feature = "ffi")]
+    pub fn welcome_messages(&self) -> &[MlsMessage] {
+        &self.welcome_messages
+    }
+
+    /// Ratchet tree that can be sent out of band if
+    /// `ratchet_tree_extension` is not used according to
+    /// [`MlsRules::commit_options`].
+    #[cfg(feature = "ffi")]
+    pub fn ratchet_tree(&self) -> Option<&ExportedTree<'static>> {
+        self.ratchet_tree.as_ref()
+    }
+
+    /// A group info that can be provided to new members in order to enable external commit
+    /// functionality. This value is set if [`MlsRules::commit_options`] returns
+    /// `allow_external_commit` set to true.
+    #[cfg(feature = "ffi")]
+    pub fn external_commit_group_info(&self) -> Option<&MlsMessage> {
+        self.external_commit_group_info.as_ref()
+    }
+
+    /// Proposals that were received in the prior epoch but not included in the following commit.
+    #[cfg(all(feature = "ffi", feature = "by_ref_proposal"))]
+    pub fn unused_proposals(&self) -> &[crate::mls_rules::ProposalInfo<Proposal>] {
+        &self.unused_proposals
+    }
+}
+
+/// Build a commit with multiple proposals by-value.
+///
+/// Proposals within a commit can be by-value or by-reference.
+/// Proposals received during the current epoch will be added to the resulting
+/// commit by-reference automatically so long as they pass the rules defined
+/// in the current
+/// [proposal rules](crate::client_builder::ClientBuilder::mls_rules).
+pub struct CommitBuilder<'a, C>
+where
+    C: ClientConfig + Clone,
+{
+    group: &'a mut Group<C>,
+    pub(super) proposals: Vec<Proposal>,
+    authenticated_data: Vec<u8>,
+    group_info_extensions: ExtensionList,
+    new_signer: Option<SignatureSecretKey>,
+    new_signing_identity: Option<SigningIdentity>,
+}
+
+impl<'a, C> CommitBuilder<'a, C>
+where
+    C: ClientConfig + Clone,
+{
+    /// Insert an [`AddProposal`](crate::group::proposal::AddProposal) into
+    /// the current commit that is being built.
+    pub fn add_member(mut self, key_package: MlsMessage) -> Result<CommitBuilder<'a, C>, MlsError> {
+        let proposal = self.group.add_proposal(key_package)?;
+        self.proposals.push(proposal);
+        Ok(self)
+    }
+
+    /// Set group info extensions that will be inserted into the resulting
+    /// [welcome messages](CommitOutput::welcome_messages) for new members.
+    ///
+    /// Group info extensions that are transmitted as part of a welcome message
+    /// are encrypted along with other private values.
+    ///
+    /// These extensions can be retrieved as part of
+    /// [`NewMemberInfo`](crate::group::NewMemberInfo) that is returned
+    /// by joining the group via
+    /// [`Client::join_group`](crate::Client::join_group).
+    pub fn set_group_info_ext(self, extensions: ExtensionList) -> Self {
+        Self {
+            group_info_extensions: extensions,
+            ..self
+        }
+    }
+
+    /// Insert a [`RemoveProposal`](crate::group::proposal::RemoveProposal) into
+    /// the current commit that is being built.
+    pub fn remove_member(mut self, index: u32) -> Result<Self, MlsError> {
+        let proposal = self.group.remove_proposal(index)?;
+        self.proposals.push(proposal);
+        Ok(self)
+    }
+
+    /// Insert a
+    /// [`GroupContextExtensions`](crate::group::proposal::Proposal::GroupContextExtensions)
+    /// into the current commit that is being built.
+    pub fn set_group_context_ext(mut self, extensions: ExtensionList) -> Result<Self, MlsError> {
+        let proposal = self.group.group_context_extensions_proposal(extensions);
+        self.proposals.push(proposal);
+        Ok(self)
+    }
+
+    /// Insert a
+    /// [`PreSharedKeyProposal`](crate::group::proposal::PreSharedKeyProposal) with
+    /// an external PSK into the current commit that is being built.
+    #[cfg(feature = "psk")]
+    pub fn add_external_psk(mut self, psk_id: ExternalPskId) -> Result<Self, MlsError> {
+        let key_id = JustPreSharedKeyID::External(psk_id);
+        let proposal = self.group.psk_proposal(key_id)?;
+        self.proposals.push(proposal);
+        Ok(self)
+    }
+
+    /// Insert a
+    /// [`PreSharedKeyProposal`](crate::group::proposal::PreSharedKeyProposal) with
+    /// a resumption PSK into the current commit that is being built.
+    #[cfg(feature = "psk")]
+    pub fn add_resumption_psk(mut self, psk_epoch: u64) -> Result<Self, MlsError> {
+        let psk_id = ResumptionPsk {
+            psk_epoch,
+            usage: ResumptionPSKUsage::Application,
+            psk_group_id: PskGroupId(self.group.group_id().to_vec()),
+        };
+
+        let key_id = JustPreSharedKeyID::Resumption(psk_id);
+        let proposal = self.group.psk_proposal(key_id)?;
+        self.proposals.push(proposal);
+        Ok(self)
+    }
+
+    /// Insert a [`ReInitProposal`](crate::group::proposal::ReInitProposal) into
+    /// the current commit that is being built.
+    pub fn reinit(
+        mut self,
+        group_id: Option<Vec<u8>>,
+        version: ProtocolVersion,
+        cipher_suite: CipherSuite,
+        extensions: ExtensionList,
+    ) -> Result<Self, MlsError> {
+        let proposal = self
+            .group
+            .reinit_proposal(group_id, version, cipher_suite, extensions)?;
+
+        self.proposals.push(proposal);
+        Ok(self)
+    }
+
+    /// Insert a [`CustomProposal`](crate::group::proposal::CustomProposal) into
+    /// the current commit that is being built.
+    #[cfg(feature = "custom_proposal")]
+    pub fn custom_proposal(mut self, proposal: CustomProposal) -> Self {
+        self.proposals.push(Proposal::Custom(proposal));
+        self
+    }
+
+    /// Insert a proposal that was previously constructed such as when a
+    /// proposal is returned from
+    /// [`StateUpdate::unused_proposals`](super::StateUpdate::unused_proposals).
+    pub fn raw_proposal(mut self, proposal: Proposal) -> Self {
+        self.proposals.push(proposal);
+        self
+    }
+
+    /// Insert proposals that were previously constructed such as when a
+    /// proposal is returned from
+    /// [`StateUpdate::unused_proposals`](super::StateUpdate::unused_proposals).
+    pub fn raw_proposals(mut self, mut proposals: Vec<Proposal>) -> Self {
+        self.proposals.append(&mut proposals);
+        self
+    }
+
+    /// Add additional authenticated data to the commit.
+    ///
+    /// # Warning
+    ///
+    /// The data provided here is always sent unencrypted.
+    pub fn authenticated_data(self, authenticated_data: Vec<u8>) -> Self {
+        Self {
+            authenticated_data,
+            ..self
+        }
+    }
+
+    /// Change the committer's signing identity as part of making this commit.
+    /// This will only succeed if the [`IdentityProvider`](crate::IdentityProvider)
+    /// in use by the group considers the credential inside this signing_identity
+    /// [valid](crate::IdentityProvider::validate_member)
+    /// and results in the same
+    /// [identity](crate::IdentityProvider::identity)
+    /// being used.
+    pub fn set_new_signing_identity(
+        self,
+        signer: SignatureSecretKey,
+        signing_identity: SigningIdentity,
+    ) -> Self {
+        Self {
+            new_signer: Some(signer),
+            new_signing_identity: Some(signing_identity),
+            ..self
+        }
+    }
+
+    /// Finalize the commit to send.
+    ///
+    /// # Errors
+    ///
+    /// This function will return an error if any of the proposals provided
+    /// are not contextually valid according to the rules defined by the
+    /// MLS RFC, or if they do not pass the custom rules defined by the current
+    /// [proposal rules](crate::client_builder::ClientBuilder::mls_rules).
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn build(self) -> Result<CommitOutput, MlsError> {
+        self.group
+            .commit_internal(
+                self.proposals,
+                None,
+                self.authenticated_data,
+                self.group_info_extensions,
+                self.new_signer,
+                self.new_signing_identity,
+            )
+            .await
+    }
+}
+
+impl<C> Group<C>
+where
+    C: ClientConfig + Clone,
+{
+    /// Perform a commit of received proposals.
+    ///
+    /// This function is the equivalent of [`Group::commit_builder`] immediately
+    /// followed by [`CommitBuilder::build`]. Any received proposals since the
+    /// last commit will be included in the resulting message by-reference.
+    ///
+    /// Data provided in the `authenticated_data` field will be placed into
+    /// the resulting commit message unencrypted.
+    ///
+    /// # Pending Commits
+    ///
+    /// When a commit is created, it is not applied immediately in order to
+    /// allow for the resolution of conflicts when multiple members of a group
+    /// attempt to make commits at the same time. For example, a central relay
+    /// can be used to decide which commit should be accepted by the group by
+    /// determining a consistent view of commit packet order for all clients.
+    ///
+    /// Pending commits are stored internally as part of the group's state
+    /// so they do not need to be tracked outside of this library. Any commit
+    /// message that is processed before calling [Group::apply_pending_commit]
+    /// will clear the currently pending commit.
+    ///
+    /// # Empty Commits
+    ///
+    /// Sending a commit that contains no proposals is a valid operation
+    /// within the MLS protocol. It is useful for providing stronger forward
+    /// secrecy and post-compromise security, especially for long running
+    /// groups when group membership does not change often.
+    ///
+    /// # Path Updates
+    ///
+    /// Path updates provide forward secrecy and post-compromise security
+    /// within the MLS protocol.
+    /// The `path_required` option returned by [`MlsRules::commit_options`](`crate::MlsRules::commit_options`)
+    /// controls the ability of a group to send a commit without a path update.
+    /// An update path will automatically be sent if there are no proposals
+    /// in the commit, or if any proposal other than
+    /// [`Add`](crate::group::proposal::Proposal::Add),
+    /// [`Psk`](crate::group::proposal::Proposal::Psk),
+    /// or [`ReInit`](crate::group::proposal::Proposal::ReInit) are part of the commit.
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn commit(&mut self, authenticated_data: Vec<u8>) -> Result<CommitOutput, MlsError> {
+        self.commit_internal(
+            vec![],
+            None,
+            authenticated_data,
+            Default::default(),
+            None,
+            None,
+        )
+        .await
+    }
+
+    /// Create a new commit builder that can include proposals
+    /// by-value.
+    pub fn commit_builder(&mut self) -> CommitBuilder<C> {
+        CommitBuilder {
+            group: self,
+            proposals: Default::default(),
+            authenticated_data: Default::default(),
+            group_info_extensions: Default::default(),
+            new_signer: Default::default(),
+            new_signing_identity: Default::default(),
+        }
+    }
+
+    /// Returns commit and optional [`MlsMessage`] containing a welcome message
+    /// for newly added members.
+    #[allow(clippy::too_many_arguments)]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(super) async fn commit_internal(
+        &mut self,
+        proposals: Vec<Proposal>,
+        external_leaf: Option<&LeafNode>,
+        authenticated_data: Vec<u8>,
+        mut welcome_group_info_extensions: ExtensionList,
+        new_signer: Option<SignatureSecretKey>,
+        new_signing_identity: Option<SigningIdentity>,
+    ) -> Result<CommitOutput, MlsError> {
+        if self.pending_commit.is_some() {
+            return Err(MlsError::ExistingPendingCommit);
+        }
+
+        if self.state.pending_reinit.is_some() {
+            return Err(MlsError::GroupUsedAfterReInit);
+        }
+
+        let mls_rules = self.config.mls_rules();
+
+        let is_external = external_leaf.is_some();
+
+        // Construct an initial Commit object with the proposals field populated from Proposals
+        // received during the current epoch, and an empty path field. Add passed in proposals
+        // by value
+        let sender = if is_external {
+            Sender::NewMemberCommit
+        } else {
+            Sender::Member(*self.private_tree.self_index)
+        };
+
+        let new_signer_ref = new_signer.as_ref().unwrap_or(&self.signer);
+        let old_signer = &self.signer;
+
+        #[cfg(feature = "std")]
+        let time = Some(crate::time::MlsTime::now());
+
+        #[cfg(not(feature = "std"))]
+        let time = None;
+
+        #[cfg(feature = "by_ref_proposal")]
+        let proposals = self.state.proposals.prepare_commit(sender, proposals);
+
+        #[cfg(not(feature = "by_ref_proposal"))]
+        let proposals = prepare_commit(sender, proposals);
+
+        let mut provisional_state = self
+            .state
+            .apply_resolved(
+                sender,
+                proposals,
+                external_leaf,
+                &self.config.identity_provider(),
+                &self.cipher_suite_provider,
+                &self.config.secret_store(),
+                &mls_rules,
+                time,
+                CommitDirection::Send,
+            )
+            .await?;
+
+        let (mut provisional_private_tree, _) =
+            self.provisional_private_tree(&provisional_state)?;
+
+        if is_external {
+            provisional_private_tree.self_index = provisional_state
+                .external_init_index
+                .ok_or(MlsError::ExternalCommitMissingExternalInit)?;
+
+            self.private_tree.self_index = provisional_private_tree.self_index;
+        }
+
+        let mut provisional_group_context = provisional_state.group_context;
+
+        // Decide whether to populate the path field: If the path field is required based on the
+        // proposals that are in the commit (see above), then it MUST be populated. Otherwise, the
+        // sender MAY omit the path field at its discretion.
+        let commit_options = mls_rules
+            .commit_options(
+                &provisional_state.public_tree.roster(),
+                &provisional_group_context.extensions,
+                &provisional_state.applied_proposals,
+            )
+            .map_err(|e| MlsError::MlsRulesError(e.into_any_error()))?;
+
+        let perform_path_update = commit_options.path_required
+            || path_update_required(&provisional_state.applied_proposals);
+
+        let (update_path, path_secrets, commit_secret) = if perform_path_update {
+            // If populating the path field: Create an UpdatePath using the new tree. Any new
+            // member (from an add proposal) MUST be excluded from the resolution during the
+            // computation of the UpdatePath. The GroupContext for this operation uses the
+            // group_id, epoch, tree_hash, and confirmed_transcript_hash values in the initial
+            // GroupContext object. The leaf_key_package for this UpdatePath must have a
+            // parent_hash extension.
+            let encap_gen = TreeKem::new(
+                &mut provisional_state.public_tree,
+                &mut provisional_private_tree,
+            )
+            .encap(
+                &mut provisional_group_context,
+                &provisional_state.indexes_of_added_kpkgs,
+                new_signer_ref,
+                self.config.leaf_properties(),
+                new_signing_identity,
+                &self.cipher_suite_provider,
+                #[cfg(test)]
+                &self.commit_modifiers,
+            )
+            .await?;
+
+            (
+                Some(encap_gen.update_path),
+                Some(encap_gen.path_secrets),
+                encap_gen.commit_secret,
+            )
+        } else {
+            // Update the tree hash, since it was not updated by encap.
+            provisional_state
+                .public_tree
+                .update_hashes(
+                    &[provisional_private_tree.self_index],
+                    &self.cipher_suite_provider,
+                )
+                .await?;
+
+            provisional_group_context.tree_hash = provisional_state
+                .public_tree
+                .tree_hash(&self.cipher_suite_provider)
+                .await?;
+
+            (None, None, PathSecret::empty(&self.cipher_suite_provider))
+        };
+
+        #[cfg(feature = "psk")]
+        let (psk_secret, psks) = self
+            .get_psk(&provisional_state.applied_proposals.psks)
+            .await?;
+
+        #[cfg(not(feature = "psk"))]
+        let psk_secret = self.get_psk();
+
+        let added_key_pkgs: Vec<_> = provisional_state
+            .applied_proposals
+            .additions
+            .iter()
+            .map(|info| info.proposal.key_package.clone())
+            .collect();
+
+        let commit = Commit {
+            proposals: provisional_state.applied_proposals.into_proposals_or_refs(),
+            path: update_path,
+        };
+
+        let mut auth_content = AuthenticatedContent::new_signed(
+            &self.cipher_suite_provider,
+            self.context(),
+            sender,
+            Content::Commit(alloc::boxed::Box::new(commit)),
+            old_signer,
+            #[cfg(feature = "private_message")]
+            self.encryption_options()?.control_wire_format(sender),
+            #[cfg(not(feature = "private_message"))]
+            WireFormat::PublicMessage,
+            authenticated_data,
+        )
+        .await?;
+
+        // Use the signature, the commit_secret and the psk_secret to advance the key schedule and
+        // compute the confirmation_tag value in the MlsPlaintext.
+        let confirmed_transcript_hash = ConfirmedTranscriptHash::create(
+            self.cipher_suite_provider(),
+            &self.state.interim_transcript_hash,
+            &auth_content,
+        )
+        .await?;
+
+        provisional_group_context.confirmed_transcript_hash = confirmed_transcript_hash;
+
+        let key_schedule_result = KeySchedule::from_key_schedule(
+            &self.key_schedule,
+            &commit_secret,
+            &provisional_group_context,
+            #[cfg(any(feature = "secret_tree_access", feature = "private_message"))]
+            self.state.public_tree.total_leaf_count(),
+            &psk_secret,
+            &self.cipher_suite_provider,
+        )
+        .await?;
+
+        let confirmation_tag = ConfirmationTag::create(
+            &key_schedule_result.confirmation_key,
+            &provisional_group_context.confirmed_transcript_hash,
+            &self.cipher_suite_provider,
+        )
+        .await?;
+
+        auth_content.auth.confirmation_tag = Some(confirmation_tag.clone());
+
+        let ratchet_tree_ext = commit_options
+            .ratchet_tree_extension
+            .then(|| RatchetTreeExt {
+                tree_data: ExportedTree::new(provisional_state.public_tree.nodes.clone()),
+            });
+
+        // Generate external commit group info if required by commit_options
+        let external_commit_group_info = match commit_options.allow_external_commit {
+            true => {
+                let mut extensions = ExtensionList::new();
+
+                extensions.set_from({
+                    key_schedule_result
+                        .key_schedule
+                        .get_external_key_pair_ext(&self.cipher_suite_provider)
+                        .await?
+                })?;
+
+                if let Some(ref ratchet_tree_ext) = ratchet_tree_ext {
+                    extensions.set_from(ratchet_tree_ext.clone())?;
+                }
+
+                let info = self
+                    .make_group_info(
+                        &provisional_group_context,
+                        extensions,
+                        &confirmation_tag,
+                        new_signer_ref,
+                    )
+                    .await?;
+
+                let msg =
+                    MlsMessage::new(self.protocol_version(), MlsMessagePayload::GroupInfo(info));
+
+                Some(msg)
+            }
+            false => None,
+        };
+
+        // Build the group info that will be placed into the welcome messages.
+        // Add the ratchet tree extension if necessary
+        if let Some(ratchet_tree_ext) = ratchet_tree_ext {
+            welcome_group_info_extensions.set_from(ratchet_tree_ext)?;
+        }
+
+        let welcome_group_info = self
+            .make_group_info(
+                &provisional_group_context,
+                welcome_group_info_extensions,
+                &confirmation_tag,
+                new_signer_ref,
+            )
+            .await?;
+
+        // Encrypt the GroupInfo using the key and nonce derived from the joiner_secret for
+        // the new epoch
+        let welcome_secret = WelcomeSecret::from_joiner_secret(
+            &self.cipher_suite_provider,
+            &key_schedule_result.joiner_secret,
+            &psk_secret,
+        )
+        .await?;
+
+        let encrypted_group_info = welcome_secret
+            .encrypt(&welcome_group_info.mls_encode_to_vec()?)
+            .await?;
+
+        // Encrypt path secrets and joiner secret to new members
+        let path_secrets = path_secrets.as_ref();
+
+        #[cfg(not(any(mls_build_async, not(feature = "rayon"))))]
+        let encrypted_path_secrets: Vec<_> = added_key_pkgs
+            .into_par_iter()
+            .zip(provisional_state.indexes_of_added_kpkgs)
+            .map(|(key_package, leaf_index)| {
+                self.encrypt_group_secrets(
+                    &key_package,
+                    leaf_index,
+                    &key_schedule_result.joiner_secret,
+                    path_secrets,
+                    #[cfg(feature = "psk")]
+                    psks.clone(),
+                    &encrypted_group_info,
+                )
+            })
+            .try_collect()?;
+
+        #[cfg(any(mls_build_async, not(feature = "rayon")))]
+        let encrypted_path_secrets = {
+            let mut secrets = Vec::new();
+
+            for (key_package, leaf_index) in added_key_pkgs
+                .into_iter()
+                .zip(provisional_state.indexes_of_added_kpkgs)
+            {
+                secrets.push(
+                    self.encrypt_group_secrets(
+                        &key_package,
+                        leaf_index,
+                        &key_schedule_result.joiner_secret,
+                        path_secrets,
+                        #[cfg(feature = "psk")]
+                        psks.clone(),
+                        &encrypted_group_info,
+                    )
+                    .await?,
+                );
+            }
+
+            secrets
+        };
+
+        let welcome_messages =
+            if commit_options.single_welcome_message && !encrypted_path_secrets.is_empty() {
+                vec![self.make_welcome_message(encrypted_path_secrets, encrypted_group_info)]
+            } else {
+                encrypted_path_secrets
+                    .into_iter()
+                    .map(|s| self.make_welcome_message(vec![s], encrypted_group_info.clone()))
+                    .collect()
+            };
+
+        let commit_message = self.format_for_wire(auth_content.clone()).await?;
+
+        let pending_commit = CommitGeneration {
+            content: auth_content,
+            pending_private_tree: provisional_private_tree,
+            pending_commit_secret: commit_secret,
+            commit_message_hash: CommitHash::compute(&self.cipher_suite_provider, &commit_message)
+                .await?,
+        };
+
+        self.pending_commit = Some(pending_commit);
+
+        let ratchet_tree = (!commit_options.ratchet_tree_extension)
+            .then(|| ExportedTree::new(provisional_state.public_tree.nodes));
+
+        if let Some(signer) = new_signer {
+            self.signer = signer;
+        }
+
+        Ok(CommitOutput {
+            commit_message,
+            welcome_messages,
+            ratchet_tree,
+            external_commit_group_info,
+            #[cfg(feature = "by_ref_proposal")]
+            unused_proposals: provisional_state.unused_proposals,
+        })
+    }
+
+    // Construct a GroupInfo reflecting the new state
+    // Group ID, epoch, tree, and confirmed transcript hash from the new state
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn make_group_info(
+        &self,
+        group_context: &GroupContext,
+        extensions: ExtensionList,
+        confirmation_tag: &ConfirmationTag,
+        signer: &SignatureSecretKey,
+    ) -> Result<GroupInfo, MlsError> {
+        let mut group_info = GroupInfo {
+            group_context: group_context.clone(),
+            extensions,
+            confirmation_tag: confirmation_tag.clone(), // The confirmation_tag from the MlsPlaintext object
+            signer: LeafIndex(self.current_member_index()),
+            signature: vec![],
+        };
+
+        group_info.grease(self.cipher_suite_provider())?;
+
+        // Sign the GroupInfo using the member's private signing key
+        group_info
+            .sign(&self.cipher_suite_provider, signer, &())
+            .await?;
+
+        Ok(group_info)
+    }
+
+    fn make_welcome_message(
+        &self,
+        secrets: Vec<EncryptedGroupSecrets>,
+        encrypted_group_info: Vec<u8>,
+    ) -> MlsMessage {
+        MlsMessage::new(
+            self.context().protocol_version,
+            MlsMessagePayload::Welcome(Welcome {
+                cipher_suite: self.context().cipher_suite,
+                secrets,
+                encrypted_group_info,
+            }),
+        )
+    }
+}
+
+#[cfg(test)]
+pub(crate) mod test_utils {
+    use alloc::vec::Vec;
+
+    use crate::{
+        crypto::SignatureSecretKey,
+        tree_kem::{leaf_node::LeafNode, TreeKemPublic, UpdatePathNode},
+    };
+
+    #[derive(Copy, Clone, Debug)]
+    pub struct CommitModifiers {
+        pub modify_leaf: fn(&mut LeafNode, &SignatureSecretKey) -> Option<SignatureSecretKey>,
+        pub modify_tree: fn(&mut TreeKemPublic),
+        pub modify_path: fn(Vec<UpdatePathNode>) -> Vec<UpdatePathNode>,
+    }
+
+    impl Default for CommitModifiers {
+        fn default() -> Self {
+            Self {
+                modify_leaf: |_, _| None,
+                modify_tree: |_| (),
+                modify_path: |a| a,
+            }
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use alloc::boxed::Box;
+
+    use mls_rs_core::{
+        error::IntoAnyError,
+        extension::ExtensionType,
+        identity::{CredentialType, IdentityProvider},
+        time::MlsTime,
+    };
+
+    use crate::{
+        crypto::test_utils::{test_cipher_suite_provider, TestCryptoProvider},
+        group::{mls_rules::DefaultMlsRules, test_utils::test_group_custom},
+        mls_rules::CommitOptions,
+        Client,
+    };
+
+    #[cfg(feature = "by_ref_proposal")]
+    use crate::extension::ExternalSendersExt;
+
+    use crate::{
+        client::test_utils::{test_client_with_key_pkg, TEST_CIPHER_SUITE, TEST_PROTOCOL_VERSION},
+        client_builder::{
+            test_utils::TestClientConfig, BaseConfig, ClientBuilder, WithCryptoProvider,
+            WithIdentityProvider,
+        },
+        client_config::ClientConfig,
+        extension::test_utils::{TestExtension, TEST_EXTENSION_TYPE},
+        group::{
+            proposal::ProposalType,
+            test_utils::{test_group_custom_config, test_n_member_group},
+        },
+        identity::test_utils::get_test_signing_identity,
+        identity::{basic::BasicIdentityProvider, test_utils::get_test_basic_credential},
+        key_package::test_utils::test_key_package_message,
+    };
+
+    use crate::extension::RequiredCapabilitiesExt;
+
+    #[cfg(feature = "psk")]
+    use crate::{
+        group::proposal::PreSharedKeyProposal,
+        psk::{JustPreSharedKeyID, PreSharedKey, PreSharedKeyID},
+    };
+
+    use super::*;
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn test_commit_builder_group() -> Group<TestClientConfig> {
+        test_group_custom_config(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, |b| {
+            b.custom_proposal_type(ProposalType::from(42))
+                .extension_type(TEST_EXTENSION_TYPE.into())
+        })
+        .await
+        .group
+    }
+
+    fn assert_commit_builder_output<C: ClientConfig>(
+        group: Group<C>,
+        mut commit_output: CommitOutput,
+        expected: Vec<Proposal>,
+        welcome_count: usize,
+    ) {
+        let plaintext = commit_output.commit_message.into_plaintext().unwrap();
+
+        let commit_data = match plaintext.content.content {
+            Content::Commit(commit) => commit,
+            #[cfg(any(feature = "private_message", feature = "by_ref_proposal"))]
+            _ => panic!("Found non-commit data"),
+        };
+
+        assert_eq!(commit_data.proposals.len(), expected.len());
+
+        commit_data.proposals.into_iter().for_each(|proposal| {
+            let proposal = match proposal {
+                ProposalOrRef::Proposal(p) => p,
+                #[cfg(feature = "by_ref_proposal")]
+                ProposalOrRef::Reference(_) => panic!("found proposal reference"),
+            };
+
+            #[cfg(feature = "psk")]
+            if let Some(psk_id) = match proposal.as_ref() {
+                Proposal::Psk(PreSharedKeyProposal { psk: PreSharedKeyID { key_id: JustPreSharedKeyID::External(psk_id), .. },}) => Some(psk_id),
+                _ => None,
+            } {
+                let found = expected.iter().any(|item| matches!(item, Proposal::Psk(PreSharedKeyProposal { psk: PreSharedKeyID { key_id: JustPreSharedKeyID::External(id), .. }}) if id == psk_id));
+
+                assert!(found)
+            } else {
+                assert!(expected.contains(&proposal));
+            }
+
+            #[cfg(not(feature = "psk"))]
+            assert!(expected.contains(&proposal));
+        });
+
+        if welcome_count > 0 {
+            let welcome_msg = commit_output.welcome_messages.pop().unwrap();
+
+            assert_eq!(welcome_msg.version, group.state.context.protocol_version);
+
+            let welcome_msg = welcome_msg.into_welcome().unwrap();
+
+            assert_eq!(welcome_msg.cipher_suite, group.state.context.cipher_suite);
+            assert_eq!(welcome_msg.secrets.len(), welcome_count);
+        } else {
+            assert!(commit_output.welcome_messages.is_empty());
+        }
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_commit_builder_add() {
+        let mut group = test_commit_builder_group().await;
+
+        let test_key_package =
+            test_key_package_message(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, "alice").await;
+
+        let commit_output = group
+            .commit_builder()
+            .add_member(test_key_package.clone())
+            .unwrap()
+            .build()
+            .await
+            .unwrap();
+
+        let expected_add = group.add_proposal(test_key_package).unwrap();
+
+        assert_commit_builder_output(group, commit_output, vec![expected_add], 1)
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_commit_builder_add_with_ext() {
+        let mut group = test_commit_builder_group().await;
+
+        let (bob_client, bob_key_package) =
+            test_client_with_key_pkg(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, "bob").await;
+
+        let ext = TestExtension { foo: 42 };
+        let mut extension_list = ExtensionList::default();
+        extension_list.set_from(ext.clone()).unwrap();
+
+        let welcome_message = group
+            .commit_builder()
+            .add_member(bob_key_package)
+            .unwrap()
+            .set_group_info_ext(extension_list)
+            .build()
+            .await
+            .unwrap()
+            .welcome_messages
+            .remove(0);
+
+        let (_, context) = bob_client.join_group(None, &welcome_message).await.unwrap();
+
+        assert_eq!(
+            context
+                .group_info_extensions
+                .get_as::<TestExtension>()
+                .unwrap()
+                .unwrap(),
+            ext
+        );
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_commit_builder_remove() {
+        let mut group = test_commit_builder_group().await;
+        let test_key_package =
+            test_key_package_message(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, "alice").await;
+
+        group
+            .commit_builder()
+            .add_member(test_key_package)
+            .unwrap()
+            .build()
+            .await
+            .unwrap();
+
+        group.apply_pending_commit().await.unwrap();
+
+        let commit_output = group
+            .commit_builder()
+            .remove_member(1)
+            .unwrap()
+            .build()
+            .await
+            .unwrap();
+
+        let expected_remove = group.remove_proposal(1).unwrap();
+
+        assert_commit_builder_output(group, commit_output, vec![expected_remove], 0);
+    }
+
+    #[cfg(feature = "psk")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_commit_builder_psk() {
+        let mut group = test_commit_builder_group().await;
+        let test_psk = ExternalPskId::new(vec![1]);
+
+        group
+            .config
+            .secret_store()
+            .insert(test_psk.clone(), PreSharedKey::from(vec![1]));
+
+        let commit_output = group
+            .commit_builder()
+            .add_external_psk(test_psk.clone())
+            .unwrap()
+            .build()
+            .await
+            .unwrap();
+
+        let key_id = JustPreSharedKeyID::External(test_psk);
+        let expected_psk = group.psk_proposal(key_id).unwrap();
+
+        assert_commit_builder_output(group, commit_output, vec![expected_psk], 0)
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_commit_builder_group_context_ext() {
+        let mut group = test_commit_builder_group().await;
+        let mut test_ext = ExtensionList::default();
+        test_ext
+            .set_from(RequiredCapabilitiesExt::default())
+            .unwrap();
+
+        let commit_output = group
+            .commit_builder()
+            .set_group_context_ext(test_ext.clone())
+            .unwrap()
+            .build()
+            .await
+            .unwrap();
+
+        let expected_ext = group.group_context_extensions_proposal(test_ext);
+
+        assert_commit_builder_output(group, commit_output, vec![expected_ext], 0);
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_commit_builder_reinit() {
+        let mut group = test_commit_builder_group().await;
+        let test_group_id = "foo".as_bytes().to_vec();
+        let test_cipher_suite = TEST_CIPHER_SUITE;
+        let test_protocol_version = TEST_PROTOCOL_VERSION;
+        let mut test_ext = ExtensionList::default();
+
+        test_ext
+            .set_from(RequiredCapabilitiesExt::default())
+            .unwrap();
+
+        let commit_output = group
+            .commit_builder()
+            .reinit(
+                Some(test_group_id.clone()),
+                test_protocol_version,
+                test_cipher_suite,
+                test_ext.clone(),
+            )
+            .unwrap()
+            .build()
+            .await
+            .unwrap();
+
+        let expected_reinit = group
+            .reinit_proposal(
+                Some(test_group_id),
+                test_protocol_version,
+                test_cipher_suite,
+                test_ext,
+            )
+            .unwrap();
+
+        assert_commit_builder_output(group, commit_output, vec![expected_reinit], 0);
+    }
+
+    #[cfg(feature = "custom_proposal")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_commit_builder_custom_proposal() {
+        let mut group = test_commit_builder_group().await;
+
+        let proposal = CustomProposal::new(42.into(), vec![0, 1]);
+
+        let commit_output = group
+            .commit_builder()
+            .custom_proposal(proposal.clone())
+            .build()
+            .await
+            .unwrap();
+
+        assert_commit_builder_output(group, commit_output, vec![Proposal::Custom(proposal)], 0);
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_commit_builder_chaining() {
+        let mut group = test_commit_builder_group().await;
+        let kp1 = test_key_package_message(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, "alice").await;
+        let kp2 = test_key_package_message(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, "bob").await;
+
+        let expected_adds = vec![
+            group.add_proposal(kp1.clone()).unwrap(),
+            group.add_proposal(kp2.clone()).unwrap(),
+        ];
+
+        let commit_output = group
+            .commit_builder()
+            .add_member(kp1)
+            .unwrap()
+            .add_member(kp2)
+            .unwrap()
+            .build()
+            .await
+            .unwrap();
+
+        assert_commit_builder_output(group, commit_output, expected_adds, 2);
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_commit_builder_empty_commit() {
+        let mut group = test_commit_builder_group().await;
+
+        let commit_output = group.commit_builder().build().await.unwrap();
+
+        assert_commit_builder_output(group, commit_output, vec![], 0);
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_commit_builder_authenticated_data() {
+        let mut group = test_commit_builder_group().await;
+        let test_data = "test".as_bytes().to_vec();
+
+        let commit_output = group
+            .commit_builder()
+            .authenticated_data(test_data.clone())
+            .build()
+            .await
+            .unwrap();
+
+        assert_eq!(
+            commit_output
+                .commit_message
+                .into_plaintext()
+                .unwrap()
+                .content
+                .authenticated_data,
+            test_data
+        );
+    }
+
+    #[cfg(feature = "by_ref_proposal")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_commit_builder_multiple_welcome_messages() {
+        let mut group = test_group_custom_config(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, |b| {
+            let options = CommitOptions::new().with_single_welcome_message(false);
+            b.mls_rules(DefaultMlsRules::new().with_commit_options(options))
+        })
+        .await;
+
+        let (alice, alice_kp) =
+            test_client_with_key_pkg(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, "a").await;
+
+        let (bob, bob_kp) =
+            test_client_with_key_pkg(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, "b").await;
+
+        group
+            .group
+            .propose_add(alice_kp.clone(), vec![])
+            .await
+            .unwrap();
+
+        group
+            .group
+            .propose_add(bob_kp.clone(), vec![])
+            .await
+            .unwrap();
+
+        let output = group.group.commit(Vec::new()).await.unwrap();
+        let welcomes = output.welcome_messages;
+
+        let cs = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+
+        for (client, kp) in [(alice, alice_kp), (bob, bob_kp)] {
+            let kp_ref = kp.key_package_reference(&cs).await.unwrap().unwrap();
+
+            let welcome = welcomes
+                .iter()
+                .find(|w| w.welcome_key_package_references().contains(&&kp_ref))
+                .unwrap();
+
+            client.join_group(None, welcome).await.unwrap();
+
+            assert_eq!(welcome.clone().into_welcome().unwrap().secrets.len(), 1);
+        }
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn commit_can_change_credential() {
+        let cs = TEST_CIPHER_SUITE;
+        let mut groups = test_n_member_group(TEST_PROTOCOL_VERSION, cs, 3).await;
+        let (identity, secret_key) = get_test_signing_identity(cs, b"member").await;
+
+        let commit_output = groups[0]
+            .group
+            .commit_builder()
+            .set_new_signing_identity(secret_key, identity.clone())
+            .build()
+            .await
+            .unwrap();
+
+        // Check that the credential was updated by in the committer's state.
+        groups[0].process_pending_commit().await.unwrap();
+        let new_member = groups[0].group.roster().member_with_index(0).unwrap();
+
+        assert_eq!(
+            new_member.signing_identity.credential,
+            get_test_basic_credential(b"member".to_vec())
+        );
+
+        assert_eq!(
+            new_member.signing_identity.signature_key,
+            identity.signature_key
+        );
+
+        // Check that the credential was updated in another member's state.
+        groups[1]
+            .process_message(commit_output.commit_message)
+            .await
+            .unwrap();
+
+        let new_member = groups[1].group.roster().member_with_index(0).unwrap();
+
+        assert_eq!(
+            new_member.signing_identity.credential,
+            get_test_basic_credential(b"member".to_vec())
+        );
+
+        assert_eq!(
+            new_member.signing_identity.signature_key,
+            identity.signature_key
+        );
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn commit_includes_tree_if_no_ratchet_tree_ext() {
+        let mut group = test_group_custom(
+            TEST_PROTOCOL_VERSION,
+            TEST_CIPHER_SUITE,
+            Default::default(),
+            None,
+            Some(CommitOptions::new().with_ratchet_tree_extension(false)),
+        )
+        .await
+        .group;
+
+        let commit = group.commit(vec![]).await.unwrap();
+
+        group.apply_pending_commit().await.unwrap();
+
+        let new_tree = group.export_tree();
+
+        assert_eq!(new_tree, commit.ratchet_tree.unwrap())
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn commit_does_not_include_tree_if_ratchet_tree_ext() {
+        let mut group = test_group_custom(
+            TEST_PROTOCOL_VERSION,
+            TEST_CIPHER_SUITE,
+            Default::default(),
+            None,
+            Some(CommitOptions::new().with_ratchet_tree_extension(true)),
+        )
+        .await
+        .group;
+
+        let commit = group.commit(vec![]).await.unwrap();
+
+        assert!(commit.ratchet_tree.is_none());
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn commit_includes_external_commit_group_info_if_requested() {
+        let mut group = test_group_custom(
+            TEST_PROTOCOL_VERSION,
+            TEST_CIPHER_SUITE,
+            Default::default(),
+            None,
+            Some(
+                CommitOptions::new()
+                    .with_allow_external_commit(true)
+                    .with_ratchet_tree_extension(false),
+            ),
+        )
+        .await
+        .group;
+
+        let commit = group.commit(vec![]).await.unwrap();
+
+        let info = commit
+            .external_commit_group_info
+            .unwrap()
+            .into_group_info()
+            .unwrap();
+
+        assert!(!info.extensions.has_extension(ExtensionType::RATCHET_TREE));
+        assert!(info.extensions.has_extension(ExtensionType::EXTERNAL_PUB));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn commit_includes_external_commit_and_tree_if_requested() {
+        let mut group = test_group_custom(
+            TEST_PROTOCOL_VERSION,
+            TEST_CIPHER_SUITE,
+            Default::default(),
+            None,
+            Some(
+                CommitOptions::new()
+                    .with_allow_external_commit(true)
+                    .with_ratchet_tree_extension(true),
+            ),
+        )
+        .await
+        .group;
+
+        let commit = group.commit(vec![]).await.unwrap();
+
+        let info = commit
+            .external_commit_group_info
+            .unwrap()
+            .into_group_info()
+            .unwrap();
+
+        assert!(info.extensions.has_extension(ExtensionType::RATCHET_TREE));
+        assert!(info.extensions.has_extension(ExtensionType::EXTERNAL_PUB));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn commit_does_not_include_external_commit_group_info_if_not_requested() {
+        let mut group = test_group_custom(
+            TEST_PROTOCOL_VERSION,
+            TEST_CIPHER_SUITE,
+            Default::default(),
+            None,
+            Some(CommitOptions::new().with_allow_external_commit(false)),
+        )
+        .await
+        .group;
+
+        let commit = group.commit(vec![]).await.unwrap();
+
+        assert!(commit.external_commit_group_info.is_none());
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn member_identity_is_validated_against_new_extensions() {
+        let alice = client_with_test_extension(b"alice").await;
+        let mut alice = alice.create_group(ExtensionList::new()).await.unwrap();
+
+        let bob = client_with_test_extension(b"bob").await;
+        let bob_kp = bob.generate_key_package_message().await.unwrap();
+
+        let mut extension_list = ExtensionList::new();
+        let extension = TestExtension { foo: b'a' };
+        extension_list.set_from(extension).unwrap();
+
+        let res = alice
+            .commit_builder()
+            .add_member(bob_kp)
+            .unwrap()
+            .set_group_context_ext(extension_list.clone())
+            .unwrap()
+            .build()
+            .await;
+
+        assert!(res.is_err());
+
+        let alex = client_with_test_extension(b"alex").await;
+
+        alice
+            .commit_builder()
+            .add_member(alex.generate_key_package_message().await.unwrap())
+            .unwrap()
+            .set_group_context_ext(extension_list.clone())
+            .unwrap()
+            .build()
+            .await
+            .unwrap();
+    }
+
+    #[cfg(feature = "by_ref_proposal")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn server_identity_is_validated_against_new_extensions() {
+        let alice = client_with_test_extension(b"alice").await;
+        let mut alice = alice.create_group(ExtensionList::new()).await.unwrap();
+
+        let mut extension_list = ExtensionList::new();
+        let extension = TestExtension { foo: b'a' };
+        extension_list.set_from(extension).unwrap();
+
+        let (alex_server, _) = get_test_signing_identity(TEST_CIPHER_SUITE, b"alex").await;
+
+        let mut alex_extensions = extension_list.clone();
+
+        alex_extensions
+            .set_from(ExternalSendersExt {
+                allowed_senders: vec![alex_server],
+            })
+            .unwrap();
+
+        let res = alice
+            .commit_builder()
+            .set_group_context_ext(alex_extensions)
+            .unwrap()
+            .build()
+            .await;
+
+        assert!(res.is_err());
+
+        let (bob_server, _) = get_test_signing_identity(TEST_CIPHER_SUITE, b"bob").await;
+
+        let mut bob_extensions = extension_list;
+
+        bob_extensions
+            .set_from(ExternalSendersExt {
+                allowed_senders: vec![bob_server],
+            })
+            .unwrap();
+
+        alice
+            .commit_builder()
+            .set_group_context_ext(bob_extensions)
+            .unwrap()
+            .build()
+            .await
+            .unwrap();
+    }
+
+    #[derive(Debug, Clone)]
+    struct IdentityProviderWithExtension(BasicIdentityProvider);
+
+    #[derive(Clone, Debug)]
+    #[cfg_attr(feature = "std", derive(thiserror::Error))]
+    #[cfg_attr(feature = "std", error("test error"))]
+    struct IdentityProviderWithExtensionError {}
+
+    impl IntoAnyError for IdentityProviderWithExtensionError {
+        #[cfg(feature = "std")]
+        fn into_dyn_error(self) -> Result<Box<dyn std::error::Error + Send + Sync>, Self> {
+            Ok(self.into())
+        }
+    }
+
+    impl IdentityProviderWithExtension {
+        // True if the identity starts with the character `foo` from `TestExtension` or if `TestExtension`
+        // is not set.
+        #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+        async fn starts_with_foo(
+            &self,
+            identity: &SigningIdentity,
+            _timestamp: Option<MlsTime>,
+            extensions: Option<&ExtensionList>,
+        ) -> bool {
+            if let Some(extensions) = extensions {
+                if let Some(ext) = extensions.get_as::<TestExtension>().unwrap() {
+                    self.identity(identity, extensions).await.unwrap()[0] == ext.foo
+                } else {
+                    true
+                }
+            } else {
+                true
+            }
+        }
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    #[cfg_attr(mls_build_async, maybe_async::must_be_async)]
+    impl IdentityProvider for IdentityProviderWithExtension {
+        type Error = IdentityProviderWithExtensionError;
+
+        async fn validate_member(
+            &self,
+            identity: &SigningIdentity,
+            timestamp: Option<MlsTime>,
+            extensions: Option<&ExtensionList>,
+        ) -> Result<(), Self::Error> {
+            self.starts_with_foo(identity, timestamp, extensions)
+                .await
+                .then_some(())
+                .ok_or(IdentityProviderWithExtensionError {})
+        }
+
+        async fn validate_external_sender(
+            &self,
+            identity: &SigningIdentity,
+            timestamp: Option<MlsTime>,
+            extensions: Option<&ExtensionList>,
+        ) -> Result<(), Self::Error> {
+            (!self.starts_with_foo(identity, timestamp, extensions).await)
+                .then_some(())
+                .ok_or(IdentityProviderWithExtensionError {})
+        }
+
+        async fn identity(
+            &self,
+            signing_identity: &SigningIdentity,
+            extensions: &ExtensionList,
+        ) -> Result<Vec<u8>, Self::Error> {
+            self.0
+                .identity(signing_identity, extensions)
+                .await
+                .map_err(|_| IdentityProviderWithExtensionError {})
+        }
+
+        async fn valid_successor(
+            &self,
+            _predecessor: &SigningIdentity,
+            _successor: &SigningIdentity,
+            _extensions: &ExtensionList,
+        ) -> Result<bool, Self::Error> {
+            Ok(true)
+        }
+
+        fn supported_types(&self) -> Vec<CredentialType> {
+            self.0.supported_types()
+        }
+    }
+
+    type ExtensionClientConfig = WithIdentityProvider<
+        IdentityProviderWithExtension,
+        WithCryptoProvider<TestCryptoProvider, BaseConfig>,
+    >;
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn client_with_test_extension(name: &[u8]) -> Client<ExtensionClientConfig> {
+        let (identity, secret_key) = get_test_signing_identity(TEST_CIPHER_SUITE, name).await;
+
+        ClientBuilder::new()
+            .crypto_provider(TestCryptoProvider::new())
+            .extension_types(vec![TEST_EXTENSION_TYPE.into()])
+            .identity_provider(IdentityProviderWithExtension(BasicIdentityProvider::new()))
+            .signing_identity(identity, secret_key, TEST_CIPHER_SUITE)
+            .build()
+    }
+}
diff --git a/src/group/confirmation_tag.rs b/src/group/confirmation_tag.rs
new file mode 100644
index 0000000..409b382
--- /dev/null
+++ b/src/group/confirmation_tag.rs
@@ -0,0 +1,150 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// Copyright by contributors to this project.
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+
+use crate::CipherSuiteProvider;
+use crate::{client::MlsError, group::transcript_hash::ConfirmedTranscriptHash};
+use alloc::vec::Vec;
+use core::{
+    fmt::{self, Debug},
+    ops::Deref,
+};
+use mls_rs_codec::{MlsDecode, MlsEncode, MlsSize};
+use mls_rs_core::error::IntoAnyError;
+
+#[derive(Clone, PartialEq, MlsSize, MlsEncode, MlsDecode)]
+#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+pub struct ConfirmationTag(
+    #[mls_codec(with = "mls_rs_codec::byte_vec")]
+    #[cfg_attr(feature = "serde", serde(with = "mls_rs_core::vec_serde"))]
+    Vec<u8>,
+);
+
+impl Debug for ConfirmationTag {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        mls_rs_core::debug::pretty_bytes(&self.0)
+            .named("ConfirmationTag")
+            .fmt(f)
+    }
+}
+
+impl Deref for ConfirmationTag {
+    type Target = Vec<u8>;
+    fn deref(&self) -> &Self::Target {
+        &self.0
+    }
+}
+
+impl ConfirmationTag {
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(crate) async fn create<P: CipherSuiteProvider>(
+        confirmation_key: &[u8],
+        confirmed_transcript_hash: &ConfirmedTranscriptHash,
+        cipher_suite_provider: &P,
+    ) -> Result<Self, MlsError> {
+        cipher_suite_provider
+            .mac(confirmation_key, confirmed_transcript_hash)
+            .await
+            .map(ConfirmationTag)
+            .map_err(|e| MlsError::CryptoProviderError(e.into_any_error()))
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(crate) async fn matches<P: CipherSuiteProvider>(
+        &self,
+        confirmation_key: &[u8],
+        confirmed_transcript_hash: &ConfirmedTranscriptHash,
+        cipher_suite_provider: &P,
+    ) -> Result<bool, MlsError> {
+        let tag = ConfirmationTag::create(
+            confirmation_key,
+            confirmed_transcript_hash,
+            cipher_suite_provider,
+        )
+        .await?;
+
+        Ok(&tag == self)
+    }
+}
+
+#[cfg(test)]
+impl ConfirmationTag {
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(crate) async fn empty<P: CipherSuiteProvider>(cipher_suite_provider: &P) -> Self {
+        Self(
+            cipher_suite_provider
+                .mac(
+                    &alloc::vec![0; cipher_suite_provider.kdf_extract_size()],
+                    &[],
+                )
+                .await
+                .unwrap(),
+        )
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::crypto::test_utils::{test_cipher_suite_provider, TestCryptoProvider};
+
+    #[cfg(target_arch = "wasm32")]
+    use wasm_bindgen_test::wasm_bindgen_test as test;
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_confirmation_tag_matching() {
+        for cipher_suite in TestCryptoProvider::all_supported_cipher_suites() {
+            let cipher_suite_provider = test_cipher_suite_provider(cipher_suite);
+
+            let confirmed_hash_a = ConfirmedTranscriptHash::from(b"foo_a".to_vec());
+
+            let confirmation_key_a = b"bar_a".to_vec();
+
+            let confirmed_hash_b = ConfirmedTranscriptHash::from(b"foo_b".to_vec());
+
+            let confirmation_key_b = b"bar_b".to_vec();
+
+            let confirmation_tag = ConfirmationTag::create(
+                &confirmation_key_a,
+                &confirmed_hash_a,
+                &cipher_suite_provider,
+            )
+            .await
+            .unwrap();
+
+            let matches = confirmation_tag
+                .matches(
+                    &confirmation_key_a,
+                    &confirmed_hash_a,
+                    &cipher_suite_provider,
+                )
+                .await
+                .unwrap();
+
+            assert!(matches);
+
+            let matches = confirmation_tag
+                .matches(
+                    &confirmation_key_b,
+                    &confirmed_hash_a,
+                    &cipher_suite_provider,
+                )
+                .await
+                .unwrap();
+
+            assert!(!matches);
+
+            let matches = confirmation_tag
+                .matches(
+                    &confirmation_key_a,
+                    &confirmed_hash_b,
+                    &cipher_suite_provider,
+                )
+                .await
+                .unwrap();
+
+            assert!(!matches);
+        }
+    }
+}
diff --git a/src/group/context.rs b/src/group/context.rs
new file mode 100644
index 0000000..4ec23a9
--- /dev/null
+++ b/src/group/context.rs
@@ -0,0 +1,98 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// Copyright by contributors to this project.
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+
+use alloc::vec;
+use alloc::vec::Vec;
+use core::fmt::{self, Debug};
+use mls_rs_codec::{MlsDecode, MlsEncode, MlsSize};
+
+use crate::{cipher_suite::CipherSuite, protocol_version::ProtocolVersion, ExtensionList};
+
+use super::ConfirmedTranscriptHash;
+
+#[derive(Clone, PartialEq, MlsSize, MlsEncode, MlsDecode)]
+#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
+#[cfg_attr(
+    all(feature = "ffi", not(test)),
+    safer_ffi_gen::ffi_type(clone, opaque)
+)]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+pub struct GroupContext {
+    pub(crate) protocol_version: ProtocolVersion,
+    pub(crate) cipher_suite: CipherSuite,
+    #[mls_codec(with = "mls_rs_codec::byte_vec")]
+    #[cfg_attr(feature = "serde", serde(with = "mls_rs_core::vec_serde"))]
+    pub(crate) group_id: Vec<u8>,
+    pub(crate) epoch: u64,
+    #[cfg_attr(feature = "serde", serde(with = "mls_rs_core::vec_serde"))]
+    #[mls_codec(with = "mls_rs_codec::byte_vec")]
+    pub(crate) tree_hash: Vec<u8>,
+    pub(crate) confirmed_transcript_hash: ConfirmedTranscriptHash,
+    pub(crate) extensions: ExtensionList,
+}
+
+impl Debug for GroupContext {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.debug_struct("GroupContext")
+            .field("protocol_version", &self.protocol_version)
+            .field("cipher_suite", &self.cipher_suite)
+            .field(
+                "group_id",
+                &mls_rs_core::debug::pretty_group_id(&self.group_id),
+            )
+            .field("epoch", &self.epoch)
+            .field(
+                "tree_hash",
+                &mls_rs_core::debug::pretty_bytes(&self.tree_hash),
+            )
+            .field("confirmed_transcript_hash", &self.confirmed_transcript_hash)
+            .field("extensions", &self.extensions)
+            .finish()
+    }
+}
+
+#[cfg_attr(all(feature = "ffi", not(test)), ::safer_ffi_gen::safer_ffi_gen)]
+impl GroupContext {
+    pub(crate) fn new_group(
+        protocol_version: ProtocolVersion,
+        cipher_suite: CipherSuite,
+        group_id: Vec<u8>,
+        tree_hash: Vec<u8>,
+        extensions: ExtensionList,
+    ) -> Self {
+        GroupContext {
+            protocol_version,
+            cipher_suite,
+            group_id,
+            epoch: 0,
+            tree_hash,
+            confirmed_transcript_hash: ConfirmedTranscriptHash::from(vec![]),
+            extensions,
+        }
+    }
+
+    /// Get the current protocol version in use by the group.
+    pub fn version(&self) -> ProtocolVersion {
+        self.protocol_version
+    }
+
+    /// Get the current cipher suite in use by the group.
+    pub fn cipher_suite(&self) -> CipherSuite {
+        self.cipher_suite
+    }
+
+    /// Get the unique identifier of this group.
+    pub fn group_id(&self) -> &[u8] {
+        &self.group_id
+    }
+
+    /// Get the current epoch number of the group's state.
+    pub fn epoch(&self) -> u64 {
+        self.epoch
+    }
+
+    pub fn extensions(&self) -> &ExtensionList {
+        &self.extensions
+    }
+}
diff --git a/src/group/epoch.rs b/src/group/epoch.rs
new file mode 100644
index 0000000..58352d6
--- /dev/null
+++ b/src/group/epoch.rs
@@ -0,0 +1,165 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// Copyright by contributors to this project.
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+
+#[cfg(feature = "psk")]
+use crate::psk::PreSharedKey;
+#[cfg(any(feature = "secret_tree_access", feature = "private_message"))]
+use crate::tree_kem::node::NodeIndex;
+#[cfg(feature = "prior_epoch")]
+use crate::{crypto::SignaturePublicKey, group::GroupContext, tree_kem::node::LeafIndex};
+use alloc::vec::Vec;
+use core::{
+    fmt::{self, Debug},
+    ops::Deref,
+};
+use mls_rs_codec::{MlsDecode, MlsEncode, MlsSize};
+use zeroize::Zeroizing;
+
+#[cfg(all(feature = "prior_epoch", feature = "private_message"))]
+use super::ciphertext_processor::GroupStateProvider;
+
+#[cfg(any(feature = "secret_tree_access", feature = "private_message"))]
+use crate::group::secret_tree::SecretTree;
+
+#[cfg(feature = "prior_epoch")]
+#[derive(Debug, Clone, MlsEncode, MlsDecode, MlsSize, PartialEq)]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+pub(crate) struct PriorEpoch {
+    pub(crate) context: GroupContext,
+    pub(crate) self_index: LeafIndex,
+    pub(crate) secrets: EpochSecrets,
+    pub(crate) signature_public_keys: Vec<Option<SignaturePublicKey>>,
+}
+
+#[cfg(feature = "prior_epoch")]
+impl PriorEpoch {
+    #[inline(always)]
+    pub(crate) fn epoch_id(&self) -> u64 {
+        self.context.epoch
+    }
+
+    #[inline(always)]
+    pub(crate) fn group_id(&self) -> &[u8] {
+        &self.context.group_id
+    }
+}
+
+#[cfg(all(feature = "private_message", feature = "prior_epoch"))]
+impl GroupStateProvider for PriorEpoch {
+    fn group_context(&self) -> &GroupContext {
+        &self.context
+    }
+
+    fn self_index(&self) -> LeafIndex {
+        self.self_index
+    }
+
+    fn epoch_secrets_mut(&mut self) -> &mut EpochSecrets {
+        &mut self.secrets
+    }
+
+    fn epoch_secrets(&self) -> &EpochSecrets {
+        &self.secrets
+    }
+}
+
+#[derive(Debug, Clone, PartialEq, MlsEncode, MlsDecode, MlsSize)]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+pub(crate) struct EpochSecrets {
+    #[cfg(feature = "psk")]
+    #[mls_codec(with = "mls_rs_codec::byte_vec")]
+    pub(crate) resumption_secret: PreSharedKey,
+    #[mls_codec(with = "mls_rs_codec::byte_vec")]
+    pub(crate) sender_data_secret: SenderDataSecret,
+    #[cfg(any(feature = "secret_tree_access", feature = "private_message"))]
+    pub(crate) secret_tree: SecretTree<NodeIndex>,
+}
+
+#[derive(Clone, PartialEq, MlsEncode, MlsDecode, MlsSize)]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+pub(crate) struct SenderDataSecret(
+    #[mls_codec(with = "mls_rs_codec::byte_vec")]
+    #[cfg_attr(feature = "serde", serde(with = "mls_rs_core::zeroizing_serde"))]
+    Zeroizing<Vec<u8>>,
+);
+
+impl Debug for SenderDataSecret {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        mls_rs_core::debug::pretty_bytes(&self.0)
+            .named("SenderDataSecret")
+            .fmt(f)
+    }
+}
+
+impl AsRef<[u8]> for SenderDataSecret {
+    fn as_ref(&self) -> &[u8] {
+        &self.0
+    }
+}
+
+impl Deref for SenderDataSecret {
+    type Target = Vec<u8>;
+
+    fn deref(&self) -> &Self::Target {
+        &self.0
+    }
+}
+
+impl From<Vec<u8>> for SenderDataSecret {
+    fn from(bytes: Vec<u8>) -> Self {
+        Self(Zeroizing::new(bytes))
+    }
+}
+
+impl From<Zeroizing<Vec<u8>>> for SenderDataSecret {
+    fn from(bytes: Zeroizing<Vec<u8>>) -> Self {
+        Self(bytes)
+    }
+}
+
+#[cfg(test)]
+pub(crate) mod test_utils {
+    use mls_rs_core::crypto::CipherSuiteProvider;
+
+    use super::*;
+    use crate::cipher_suite::CipherSuite;
+    use crate::crypto::test_utils::test_cipher_suite_provider;
+
+    #[cfg(any(feature = "secret_tree_access", feature = "private_message"))]
+    use crate::group::secret_tree::test_utils::get_test_tree;
+
+    #[cfg(feature = "prior_epoch")]
+    use crate::group::test_utils::get_test_group_context_with_id;
+
+    use crate::group::test_utils::random_bytes;
+
+    pub(crate) fn get_test_epoch_secrets(cipher_suite: CipherSuite) -> EpochSecrets {
+        let cs_provider = test_cipher_suite_provider(cipher_suite);
+
+        #[cfg(any(feature = "secret_tree_access", feature = "private_message"))]
+        let secret_tree = get_test_tree(random_bytes(cs_provider.kdf_extract_size()), 2);
+
+        EpochSecrets {
+            #[cfg(feature = "psk")]
+            resumption_secret: random_bytes(cs_provider.kdf_extract_size()).into(),
+            sender_data_secret: random_bytes(cs_provider.kdf_extract_size()).into(),
+            #[cfg(any(feature = "secret_tree_access", feature = "private_message"))]
+            secret_tree,
+        }
+    }
+
+    #[cfg(feature = "prior_epoch")]
+    pub(crate) fn get_test_epoch_with_id(
+        group_id: Vec<u8>,
+        cipher_suite: CipherSuite,
+        id: u64,
+    ) -> PriorEpoch {
+        PriorEpoch {
+            context: get_test_group_context_with_id(group_id, id, cipher_suite),
+            self_index: LeafIndex(0),
+            secrets: get_test_epoch_secrets(cipher_suite),
+            signature_public_keys: Default::default(),
+        }
+    }
+}
diff --git a/src/group/exported_tree.rs b/src/group/exported_tree.rs
new file mode 100644
index 0000000..acf507f
--- /dev/null
+++ b/src/group/exported_tree.rs
@@ -0,0 +1,51 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// Copyright by contributors to this project.
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+
+use alloc::{borrow::Cow, vec::Vec};
+use mls_rs_codec::{MlsDecode, MlsEncode, MlsSize};
+
+use crate::{client::MlsError, tree_kem::node::NodeVec};
+
+#[cfg_attr(
+    all(feature = "ffi", not(test)),
+    safer_ffi_gen::ffi_type(clone, opaque)
+)]
+#[derive(Debug, MlsSize, MlsEncode, MlsDecode, PartialEq, Clone)]
+pub struct ExportedTree<'a>(pub(crate) Cow<'a, NodeVec>);
+
+#[cfg_attr(all(feature = "ffi", not(test)), ::safer_ffi_gen::safer_ffi_gen)]
+impl<'a> ExportedTree<'a> {
+    pub(crate) fn new(node_data: NodeVec) -> Self {
+        Self(Cow::Owned(node_data))
+    }
+
+    pub(crate) fn new_borrowed(node_data: &'a NodeVec) -> Self {
+        Self(Cow::Borrowed(node_data))
+    }
+
+    pub fn to_bytes(&self) -> Result<Vec<u8>, MlsError> {
+        self.mls_encode_to_vec().map_err(Into::into)
+    }
+
+    pub fn byte_size(&self) -> usize {
+        self.mls_encoded_len()
+    }
+
+    pub fn into_owned(self) -> ExportedTree<'static> {
+        ExportedTree(Cow::Owned(self.0.into_owned()))
+    }
+}
+
+#[cfg_attr(all(feature = "ffi", not(test)), ::safer_ffi_gen::safer_ffi_gen)]
+impl ExportedTree<'static> {
+    pub fn from_bytes(bytes: &[u8]) -> Result<Self, MlsError> {
+        Self::mls_decode(&mut &*bytes).map_err(Into::into)
+    }
+}
+
+impl From<ExportedTree<'_>> for NodeVec {
+    fn from(value: ExportedTree) -> Self {
+        value.0.into_owned()
+    }
+}
diff --git a/src/group/external_commit.rs b/src/group/external_commit.rs
new file mode 100644
index 0000000..34b1042
--- /dev/null
+++ b/src/group/external_commit.rs
@@ -0,0 +1,266 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// Copyright by contributors to this project.
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+
+use mls_rs_core::{crypto::SignatureSecretKey, identity::SigningIdentity};
+
+use crate::{
+    client_config::ClientConfig,
+    group::{
+        cipher_suite_provider,
+        epoch::SenderDataSecret,
+        key_schedule::{InitSecret, KeySchedule},
+        proposal::{ExternalInit, Proposal, RemoveProposal},
+        EpochSecrets, ExternalPubExt, LeafIndex, LeafNode, MlsError, TreeKemPrivate,
+    },
+    Group, MlsMessage,
+};
+
+#[cfg(any(feature = "secret_tree_access", feature = "private_message"))]
+use crate::group::secret_tree::SecretTree;
+
+#[cfg(feature = "custom_proposal")]
+use crate::group::{
+    framing::MlsMessagePayload,
+    message_processor::{EventOrContent, MessageProcessor},
+    message_signature::AuthenticatedContent,
+    message_verifier::verify_plaintext_authentication,
+    CustomProposal,
+};
+
+use alloc::vec;
+use alloc::vec::Vec;
+
+#[cfg(feature = "psk")]
+use mls_rs_core::psk::{ExternalPskId, PreSharedKey};
+
+#[cfg(feature = "psk")]
+use crate::group::{
+    PreSharedKeyProposal, {JustPreSharedKeyID, PreSharedKeyID},
+};
+
+use super::{validate_group_info_joiner, ExportedTree};
+
+/// A builder that aids with the construction of an external commit.
+#[cfg_attr(all(feature = "ffi", not(test)), safer_ffi_gen::ffi_type(opaque))]
+pub struct ExternalCommitBuilder<C: ClientConfig> {
+    signer: SignatureSecretKey,
+    signing_identity: SigningIdentity,
+    config: C,
+    tree_data: Option<ExportedTree<'static>>,
+    to_remove: Option<u32>,
+    #[cfg(feature = "psk")]
+    external_psks: Vec<ExternalPskId>,
+    authenticated_data: Vec<u8>,
+    #[cfg(feature = "custom_proposal")]
+    custom_proposals: Vec<Proposal>,
+    #[cfg(feature = "custom_proposal")]
+    received_custom_proposals: Vec<MlsMessage>,
+}
+
+impl<C: ClientConfig> ExternalCommitBuilder<C> {
+    pub(crate) fn new(
+        signer: SignatureSecretKey,
+        signing_identity: SigningIdentity,
+        config: C,
+    ) -> Self {
+        Self {
+            tree_data: None,
+            to_remove: None,
+            authenticated_data: Vec::new(),
+            signer,
+            signing_identity,
+            config,
+            #[cfg(feature = "psk")]
+            external_psks: Vec::new(),
+            #[cfg(feature = "custom_proposal")]
+            custom_proposals: Vec::new(),
+            #[cfg(feature = "custom_proposal")]
+            received_custom_proposals: Vec::new(),
+        }
+    }
+
+    #[must_use]
+    /// Use external tree data if the GroupInfo message does not contain a
+    /// [`RatchetTreeExt`](crate::extension::built_in::RatchetTreeExt)
+    pub fn with_tree_data(self, tree_data: ExportedTree<'static>) -> Self {
+        Self {
+            tree_data: Some(tree_data),
+            ..self
+        }
+    }
+
+    #[must_use]
+    /// Propose the removal of an old version of the client as part of the external commit.
+    /// Only one such proposal is allowed.
+    pub fn with_removal(self, to_remove: u32) -> Self {
+        Self {
+            to_remove: Some(to_remove),
+            ..self
+        }
+    }
+
+    #[must_use]
+    /// Add plaintext authenticated data to the resulting commit message.
+    pub fn with_authenticated_data(self, data: Vec<u8>) -> Self {
+        Self {
+            authenticated_data: data,
+            ..self
+        }
+    }
+
+    #[cfg(feature = "psk")]
+    #[must_use]
+    /// Add an external psk to the group as part of the external commit.
+    pub fn with_external_psk(mut self, psk: ExternalPskId) -> Self {
+        self.external_psks.push(psk);
+        self
+    }
+
+    #[cfg(feature = "custom_proposal")]
+    #[must_use]
+    /// Insert a [`CustomProposal`] into the current commit that is being built.
+    pub fn with_custom_proposal(mut self, proposal: CustomProposal) -> Self {
+        self.custom_proposals.push(Proposal::Custom(proposal));
+        self
+    }
+
+    #[cfg(all(feature = "custom_proposal", feature = "by_ref_proposal"))]
+    #[must_use]
+    /// Insert a [`CustomProposal`] received from a current group member into the current
+    /// commit that is being built.
+    ///
+    /// # Warning
+    ///
+    /// The authenticity of the proposal is NOT fully verified. It is only verified the
+    /// same way as by [`ExternalGroup`](`crate::external_client::ExternalGroup`).
+    /// The proposal MUST be an MlsPlaintext, else the [`Self::build`] function will fail.
+    pub fn with_received_custom_proposal(mut self, proposal: MlsMessage) -> Self {
+        self.received_custom_proposals.push(proposal);
+        self
+    }
+
+    /// Build the external commit using a GroupInfo message provided by an existing group member.
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn build(self, group_info: MlsMessage) -> Result<(Group<C>, MlsMessage), MlsError> {
+        let protocol_version = group_info.version;
+
+        if !self.config.version_supported(protocol_version) {
+            return Err(MlsError::UnsupportedProtocolVersion(protocol_version));
+        }
+
+        let group_info = group_info
+            .into_group_info()
+            .ok_or(MlsError::UnexpectedMessageType)?;
+
+        let cipher_suite = cipher_suite_provider(
+            self.config.crypto_provider(),
+            group_info.group_context.cipher_suite,
+        )?;
+
+        let external_pub_ext = group_info
+            .extensions
+            .get_as::<ExternalPubExt>()?
+            .ok_or(MlsError::MissingExternalPubExtension)?;
+
+        let public_tree = validate_group_info_joiner(
+            protocol_version,
+            &group_info,
+            self.tree_data,
+            &self.config.identity_provider(),
+            &cipher_suite,
+        )
+        .await?;
+
+        let (leaf_node, _) = LeafNode::generate(
+            &cipher_suite,
+            self.config.leaf_properties(),
+            self.signing_identity,
+            &self.signer,
+            self.config.lifetime(),
+        )
+        .await?;
+
+        let (init_secret, kem_output) =
+            InitSecret::encode_for_external(&cipher_suite, &external_pub_ext.external_pub).await?;
+
+        let epoch_secrets = EpochSecrets {
+            #[cfg(feature = "psk")]
+            resumption_secret: PreSharedKey::new(vec![]),
+            sender_data_secret: SenderDataSecret::from(vec![]),
+            #[cfg(any(feature = "secret_tree_access", feature = "private_message"))]
+            secret_tree: SecretTree::empty(),
+        };
+
+        let (mut group, _) = Group::join_with(
+            self.config,
+            group_info,
+            public_tree,
+            KeySchedule::new(init_secret),
+            epoch_secrets,
+            TreeKemPrivate::new_for_external(),
+            None,
+            self.signer,
+        )
+        .await?;
+
+        #[cfg(feature = "psk")]
+        let psk_ids = self
+            .external_psks
+            .into_iter()
+            .map(|psk_id| PreSharedKeyID::new(JustPreSharedKeyID::External(psk_id), &cipher_suite))
+            .collect::<Result<Vec<_>, MlsError>>()?;
+
+        let mut proposals = vec![Proposal::ExternalInit(ExternalInit { kem_output })];
+
+        #[cfg(feature = "psk")]
+        proposals.extend(
+            psk_ids
+                .into_iter()
+                .map(|psk| Proposal::Psk(PreSharedKeyProposal { psk })),
+        );
+
+        #[cfg(feature = "custom_proposal")]
+        {
+            let mut custom_proposals = self.custom_proposals;
+            proposals.append(&mut custom_proposals);
+        }
+
+        #[cfg(all(feature = "custom_proposal", feature = "by_ref_proposal"))]
+        for message in self.received_custom_proposals {
+            let MlsMessagePayload::Plain(plaintext) = message.payload else {
+                return Err(MlsError::UnexpectedMessageType);
+            };
+
+            let auth_content = AuthenticatedContent::from(plaintext.clone());
+
+            verify_plaintext_authentication(&cipher_suite, plaintext, None, None, &group.state)
+                .await?;
+
+            group
+                .process_event_or_content(EventOrContent::Content(auth_content), true, None)
+                .await?;
+        }
+
+        if let Some(r) = self.to_remove {
+            proposals.push(Proposal::Remove(RemoveProposal {
+                to_remove: LeafIndex(r),
+            }));
+        }
+
+        let commit_output = group
+            .commit_internal(
+                proposals,
+                Some(&leaf_node),
+                self.authenticated_data,
+                Default::default(),
+                None,
+                None,
+            )
+            .await?;
+
+        group.apply_pending_commit().await?;
+
+        Ok((group, commit_output.commit_message))
+    }
+}
diff --git a/src/group/framing.rs b/src/group/framing.rs
new file mode 100644
index 0000000..8663b96
--- /dev/null
+++ b/src/group/framing.rs
@@ -0,0 +1,741 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// Copyright by contributors to this project.
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+
+use core::ops::Deref;
+
+use crate::{client::MlsError, tree_kem::node::LeafIndex, KeyPackage, KeyPackageRef};
+
+use super::{Commit, FramedContentAuthData, GroupInfo, MembershipTag, Welcome};
+
+#[cfg(feature = "by_ref_proposal")]
+use crate::{group::Proposal, mls_rules::ProposalRef};
+
+use alloc::vec::Vec;
+use core::fmt::{self, Debug};
+use mls_rs_codec::{MlsDecode, MlsEncode, MlsSize};
+use mls_rs_core::{
+    crypto::{CipherSuite, CipherSuiteProvider},
+    protocol_version::ProtocolVersion,
+};
+use zeroize::ZeroizeOnDrop;
+
+#[cfg(feature = "private_message")]
+use alloc::boxed::Box;
+
+#[cfg(feature = "custom_proposal")]
+use crate::group::proposal::{CustomProposal, ProposalOrRef};
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq, MlsSize, MlsEncode, MlsDecode)]
+#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
+#[repr(u8)]
+pub enum ContentType {
+    #[cfg(feature = "private_message")]
+    Application = 1u8,
+    #[cfg(feature = "by_ref_proposal")]
+    Proposal = 2u8,
+    Commit = 3u8,
+}
+
+impl From<&Content> for ContentType {
+    fn from(content: &Content) -> Self {
+        match content {
+            #[cfg(feature = "private_message")]
+            Content::Application(_) => ContentType::Application,
+            #[cfg(feature = "by_ref_proposal")]
+            Content::Proposal(_) => ContentType::Proposal,
+            Content::Commit(_) => ContentType::Commit,
+        }
+    }
+}
+
+#[cfg_attr(
+    all(feature = "ffi", not(test)),
+    safer_ffi_gen::ffi_type(clone, opaque)
+)]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, MlsSize, MlsEncode, MlsDecode)]
+#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[repr(u8)]
+#[non_exhaustive]
+/// Description of a [`MlsMessage`] sender
+pub enum Sender {
+    /// Current group member index.
+    Member(u32) = 1u8,
+    /// An external entity sending a proposal proposal identified by an index
+    /// in the current
+    /// [`ExternalSendersExt`](crate::extension::ExternalSendersExt) stored in
+    /// group context extensions.
+    #[cfg(feature = "by_ref_proposal")]
+    External(u32) = 2u8,
+    /// A new member proposing their own addition to the group.
+    #[cfg(feature = "by_ref_proposal")]
+    NewMemberProposal = 3u8,
+    /// A member sending an external commit.
+    NewMemberCommit = 4u8,
+}
+
+impl From<LeafIndex> for Sender {
+    fn from(leaf_index: LeafIndex) -> Self {
+        Sender::Member(*leaf_index)
+    }
+}
+
+impl From<u32> for Sender {
+    fn from(leaf_index: u32) -> Self {
+        Sender::Member(leaf_index)
+    }
+}
+
+#[derive(Clone, PartialEq, Eq, MlsSize, MlsEncode, MlsDecode, ZeroizeOnDrop)]
+#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+pub struct ApplicationData(
+    #[mls_codec(with = "mls_rs_codec::byte_vec")]
+    #[cfg_attr(feature = "serde", serde(with = "mls_rs_core::vec_serde"))]
+    Vec<u8>,
+);
+
+impl Debug for ApplicationData {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        mls_rs_core::debug::pretty_bytes(&self.0)
+            .named("ApplicationData")
+            .fmt(f)
+    }
+}
+
+impl From<Vec<u8>> for ApplicationData {
+    fn from(data: Vec<u8>) -> Self {
+        Self(data)
+    }
+}
+
+impl Deref for ApplicationData {
+    type Target = [u8];
+
+    fn deref(&self) -> &Self::Target {
+        &self.0
+    }
+}
+
+impl ApplicationData {
+    /// Underlying message content.
+    pub fn as_bytes(&self) -> &[u8] {
+        &self.0
+    }
+}
+
+#[derive(Clone, Debug, PartialEq, MlsSize, MlsEncode, MlsDecode)]
+#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[repr(u8)]
+pub(crate) enum Content {
+    #[cfg(feature = "private_message")]
+    Application(ApplicationData) = 1u8,
+    #[cfg(feature = "by_ref_proposal")]
+    Proposal(alloc::boxed::Box<Proposal>) = 2u8,
+    Commit(alloc::boxed::Box<Commit>) = 3u8,
+}
+
+impl Content {
+    pub fn content_type(&self) -> ContentType {
+        self.into()
+    }
+}
+
+#[derive(Clone, Debug, PartialEq)]
+#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
+pub(crate) struct PublicMessage {
+    pub content: FramedContent,
+    pub auth: FramedContentAuthData,
+    pub membership_tag: Option<MembershipTag>,
+}
+
+impl MlsSize for PublicMessage {
+    fn mls_encoded_len(&self) -> usize {
+        self.content.mls_encoded_len()
+            + self.auth.mls_encoded_len()
+            + self
+                .membership_tag
+                .as_ref()
+                .map_or(0, |tag| tag.mls_encoded_len())
+    }
+}
+
+impl MlsEncode for PublicMessage {
+    fn mls_encode(&self, writer: &mut Vec<u8>) -> Result<(), mls_rs_codec::Error> {
+        self.content.mls_encode(writer)?;
+        self.auth.mls_encode(writer)?;
+
+        self.membership_tag
+            .as_ref()
+            .map_or(Ok(()), |tag| tag.mls_encode(writer))
+    }
+}
+
+impl MlsDecode for PublicMessage {
+    fn mls_decode(reader: &mut &[u8]) -> Result<Self, mls_rs_codec::Error> {
+        let content = FramedContent::mls_decode(reader)?;
+        let auth = FramedContentAuthData::mls_decode(reader, content.content_type())?;
+
+        let membership_tag = match content.sender {
+            Sender::Member(_) => Some(MembershipTag::mls_decode(reader)?),
+            _ => None,
+        };
+
+        Ok(Self {
+            content,
+            auth,
+            membership_tag,
+        })
+    }
+}
+
+#[cfg(feature = "private_message")]
+#[derive(Clone, Debug, PartialEq)]
+pub(crate) struct PrivateMessageContent {
+    pub content: Content,
+    pub auth: FramedContentAuthData,
+}
+
+#[cfg(feature = "private_message")]
+impl MlsSize for PrivateMessageContent {
+    fn mls_encoded_len(&self) -> usize {
+        let content_len_without_type = match &self.content {
+            Content::Application(c) => c.mls_encoded_len(),
+            #[cfg(feature = "by_ref_proposal")]
+            Content::Proposal(c) => c.mls_encoded_len(),
+            Content::Commit(c) => c.mls_encoded_len(),
+        };
+
+        content_len_without_type + self.auth.mls_encoded_len()
+    }
+}
+
+#[cfg(feature = "private_message")]
+impl MlsEncode for PrivateMessageContent {
+    fn mls_encode(&self, writer: &mut Vec<u8>) -> Result<(), mls_rs_codec::Error> {
+        match &self.content {
+            Content::Application(c) => c.mls_encode(writer),
+            #[cfg(feature = "by_ref_proposal")]
+            Content::Proposal(c) => c.mls_encode(writer),
+            Content::Commit(c) => c.mls_encode(writer),
+        }?;
+
+        self.auth.mls_encode(writer)?;
+
+        Ok(())
+    }
+}
+
+#[cfg(feature = "private_message")]
+impl PrivateMessageContent {
+    pub(crate) fn mls_decode(
+        reader: &mut &[u8],
+        content_type: ContentType,
+    ) -> Result<Self, mls_rs_codec::Error> {
+        let content = match content_type {
+            ContentType::Application => Content::Application(ApplicationData::mls_decode(reader)?),
+            #[cfg(feature = "by_ref_proposal")]
+            ContentType::Proposal => Content::Proposal(Box::new(Proposal::mls_decode(reader)?)),
+            ContentType::Commit => {
+                Content::Commit(alloc::boxed::Box::new(Commit::mls_decode(reader)?))
+            }
+        };
+
+        let auth = FramedContentAuthData::mls_decode(reader, content.content_type())?;
+
+        if reader.iter().any(|&i| i != 0u8) {
+            // #[cfg(feature = "std")]
+            // return Err(mls_rs_codec::Error::Custom(
+            //    "non-zero padding bytes discovered".to_string(),
+            // ));
+
+            // #[cfg(not(feature = "std"))]
+            return Err(mls_rs_codec::Error::Custom(5));
+        }
+
+        Ok(Self { content, auth })
+    }
+}
+
+#[cfg(feature = "private_message")]
+#[derive(Clone, PartialEq, Eq, MlsSize, MlsEncode, MlsDecode)]
+pub struct PrivateContentAAD {
+    #[mls_codec(with = "mls_rs_codec::byte_vec")]
+    pub group_id: Vec<u8>,
+    pub epoch: u64,
+    pub content_type: ContentType,
+    #[mls_codec(with = "mls_rs_codec::byte_vec")]
+    pub authenticated_data: Vec<u8>,
+}
+
+#[cfg(feature = "private_message")]
+impl Debug for PrivateContentAAD {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.debug_struct("PrivateContentAAD")
+            .field(
+                "group_id",
+                &mls_rs_core::debug::pretty_group_id(&self.group_id),
+            )
+            .field("epoch", &self.epoch)
+            .field("content_type", &self.content_type)
+            .field(
+                "authenticated_data",
+                &mls_rs_core::debug::pretty_bytes(&self.authenticated_data),
+            )
+            .finish()
+    }
+}
+
+#[cfg(feature = "private_message")]
+#[derive(Clone, PartialEq, Eq, MlsSize, MlsEncode, MlsDecode)]
+#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
+pub struct PrivateMessage {
+    #[mls_codec(with = "mls_rs_codec::byte_vec")]
+    pub group_id: Vec<u8>,
+    pub epoch: u64,
+    pub content_type: ContentType,
+    #[mls_codec(with = "mls_rs_codec::byte_vec")]
+    pub authenticated_data: Vec<u8>,
+    #[mls_codec(with = "mls_rs_codec::byte_vec")]
+    pub encrypted_sender_data: Vec<u8>,
+    #[mls_codec(with = "mls_rs_codec::byte_vec")]
+    pub ciphertext: Vec<u8>,
+}
+
+#[cfg(feature = "private_message")]
+impl Debug for PrivateMessage {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.debug_struct("PrivateMessage")
+            .field(
+                "group_id",
+                &mls_rs_core::debug::pretty_group_id(&self.group_id),
+            )
+            .field("epoch", &self.epoch)
+            .field("content_type", &self.content_type)
+            .field(
+                "authenticated_data",
+                &mls_rs_core::debug::pretty_bytes(&self.authenticated_data),
+            )
+            .field(
+                "encrypted_sender_data",
+                &mls_rs_core::debug::pretty_bytes(&self.encrypted_sender_data),
+            )
+            .field(
+                "ciphertext",
+                &mls_rs_core::debug::pretty_bytes(&self.ciphertext),
+            )
+            .finish()
+    }
+}
+
+#[cfg(feature = "private_message")]
+impl From<&PrivateMessage> for PrivateContentAAD {
+    fn from(ciphertext: &PrivateMessage) -> Self {
+        Self {
+            group_id: ciphertext.group_id.clone(),
+            epoch: ciphertext.epoch,
+            content_type: ciphertext.content_type,
+            authenticated_data: ciphertext.authenticated_data.clone(),
+        }
+    }
+}
+
+#[derive(Clone, Debug, PartialEq, MlsSize, MlsEncode, MlsDecode)]
+#[cfg_attr(
+    all(feature = "ffi", not(test)),
+    ::safer_ffi_gen::ffi_type(clone, opaque)
+)]
+#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
+/// A MLS protocol message for sending data over the wire.
+pub struct MlsMessage {
+    pub(crate) version: ProtocolVersion,
+    pub(crate) payload: MlsMessagePayload,
+}
+
+#[cfg_attr(all(feature = "ffi", not(test)), ::safer_ffi_gen::safer_ffi_gen)]
+#[allow(dead_code)]
+impl MlsMessage {
+    pub(crate) fn new(version: ProtocolVersion, payload: MlsMessagePayload) -> MlsMessage {
+        Self { version, payload }
+    }
+
+    #[inline(always)]
+    pub(crate) fn into_plaintext(self) -> Option<PublicMessage> {
+        match self.payload {
+            MlsMessagePayload::Plain(plaintext) => Some(plaintext),
+            _ => None,
+        }
+    }
+
+    #[cfg(feature = "private_message")]
+    #[inline(always)]
+    pub(crate) fn into_ciphertext(self) -> Option<PrivateMessage> {
+        match self.payload {
+            MlsMessagePayload::Cipher(ciphertext) => Some(ciphertext),
+            _ => None,
+        }
+    }
+
+    #[inline(always)]
+    pub(crate) fn into_welcome(self) -> Option<Welcome> {
+        match self.payload {
+            MlsMessagePayload::Welcome(welcome) => Some(welcome),
+            _ => None,
+        }
+    }
+
+    #[inline(always)]
+    pub fn into_group_info(self) -> Option<GroupInfo> {
+        match self.payload {
+            MlsMessagePayload::GroupInfo(info) => Some(info),
+            _ => None,
+        }
+    }
+
+    #[inline(always)]
+    pub fn as_group_info(&self) -> Option<&GroupInfo> {
+        match &self.payload {
+            MlsMessagePayload::GroupInfo(info) => Some(info),
+            _ => None,
+        }
+    }
+
+    #[inline(always)]
+    pub fn into_key_package(self) -> Option<KeyPackage> {
+        match self.payload {
+            MlsMessagePayload::KeyPackage(kp) => Some(kp),
+            _ => None,
+        }
+    }
+
+    /// The wire format value describing the contents of this message.
+    pub fn wire_format(&self) -> WireFormat {
+        match self.payload {
+            MlsMessagePayload::Plain(_) => WireFormat::PublicMessage,
+            #[cfg(feature = "private_message")]
+            MlsMessagePayload::Cipher(_) => WireFormat::PrivateMessage,
+            MlsMessagePayload::Welcome(_) => WireFormat::Welcome,
+            MlsMessagePayload::GroupInfo(_) => WireFormat::GroupInfo,
+            MlsMessagePayload::KeyPackage(_) => WireFormat::KeyPackage,
+        }
+    }
+
+    /// The epoch that this message belongs to.
+    ///
+    /// Returns `None` if the message is [`WireFormat::KeyPackage`]
+    /// or [`WireFormat::Welcome`]
+    #[cfg_attr(all(feature = "ffi", not(test)), ::safer_ffi_gen::safer_ffi_gen_ignore)]
+    pub fn epoch(&self) -> Option<u64> {
+        match &self.payload {
+            MlsMessagePayload::Plain(p) => Some(p.content.epoch),
+            #[cfg(feature = "private_message")]
+            MlsMessagePayload::Cipher(c) => Some(c.epoch),
+            MlsMessagePayload::GroupInfo(gi) => Some(gi.group_context.epoch),
+            _ => None,
+        }
+    }
+
+    #[cfg_attr(all(feature = "ffi", not(test)), ::safer_ffi_gen::safer_ffi_gen_ignore)]
+    pub fn cipher_suite(&self) -> Option<CipherSuite> {
+        match &self.payload {
+            MlsMessagePayload::GroupInfo(i) => Some(i.group_context.cipher_suite),
+            MlsMessagePayload::Welcome(w) => Some(w.cipher_suite),
+            MlsMessagePayload::KeyPackage(k) => Some(k.cipher_suite),
+            _ => None,
+        }
+    }
+
+    pub fn group_id(&self) -> Option<&[u8]> {
+        match &self.payload {
+            MlsMessagePayload::Plain(p) => Some(&p.content.group_id),
+            #[cfg(feature = "private_message")]
+            MlsMessagePayload::Cipher(p) => Some(&p.group_id),
+            MlsMessagePayload::GroupInfo(p) => Some(&p.group_context.group_id),
+            MlsMessagePayload::KeyPackage(_) | MlsMessagePayload::Welcome(_) => None,
+        }
+    }
+
+    /// Deserialize a message from transport.
+    #[inline(never)]
+    pub fn from_bytes(bytes: &[u8]) -> Result<Self, MlsError> {
+        Self::mls_decode(&mut &*bytes).map_err(Into::into)
+    }
+
+    /// Serialize a message for transport.
+    pub fn to_bytes(&self) -> Result<Vec<u8>, MlsError> {
+        self.mls_encode_to_vec().map_err(Into::into)
+    }
+
+    /// If this is a plaintext commit message, return all custom proposals committed by value.
+    /// If this is not a plaintext or not a commit, this returns an empty list.
+    #[cfg(feature = "custom_proposal")]
+    pub fn custom_proposals_by_value(&self) -> Vec<&CustomProposal> {
+        match &self.payload {
+            MlsMessagePayload::Plain(plaintext) => match &plaintext.content.content {
+                Content::Commit(commit) => Self::find_custom_proposals(commit),
+                _ => Vec::new(),
+            },
+            _ => Vec::new(),
+        }
+    }
+
+    /// If this is a welcome message, return key package references of all members who can
+    /// join using this message.
+    pub fn welcome_key_package_references(&self) -> Vec<&KeyPackageRef> {
+        let MlsMessagePayload::Welcome(welcome) = &self.payload else {
+            return Vec::new();
+        };
+
+        welcome.secrets.iter().map(|s| &s.new_member).collect()
+    }
+
+    /// If this is a key package, return its key package reference.
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn key_package_reference<C: CipherSuiteProvider>(
+        &self,
+        cipher_suite: &C,
+    ) -> Result<Option<KeyPackageRef>, MlsError> {
+        let MlsMessagePayload::KeyPackage(kp) = &self.payload else {
+            return Ok(None);
+        };
+
+        kp.to_reference(cipher_suite).await.map(Some)
+    }
+
+    /// If this is a plaintext proposal, return the proposal reference that can be matched e.g. with
+    /// [`StateUpdate::unused_proposals`](super::StateUpdate::unused_proposals).
+    #[cfg(feature = "by_ref_proposal")]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn into_proposal_reference<C: CipherSuiteProvider>(
+        self,
+        cipher_suite: &C,
+    ) -> Result<Option<Vec<u8>>, MlsError> {
+        let MlsMessagePayload::Plain(public_message) = self.payload else {
+            return Ok(None);
+        };
+
+        ProposalRef::from_content(cipher_suite, &public_message.into())
+            .await
+            .map(|r| Some(r.to_vec()))
+    }
+}
+
+#[cfg(feature = "custom_proposal")]
+impl MlsMessage {
+    fn find_custom_proposals(commit: &Commit) -> Vec<&CustomProposal> {
+        commit
+            .proposals
+            .iter()
+            .filter_map(|p| match p {
+                ProposalOrRef::Proposal(p) => match p.as_ref() {
+                    crate::group::Proposal::Custom(p) => Some(p),
+                    _ => None,
+                },
+                _ => None,
+            })
+            .collect()
+    }
+}
+
+#[allow(clippy::large_enum_variant)]
+#[derive(Clone, Debug, PartialEq, MlsSize, MlsEncode, MlsDecode)]
+#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
+#[repr(u16)]
+pub(crate) enum MlsMessagePayload {
+    Plain(PublicMessage) = 1u16,
+    #[cfg(feature = "private_message")]
+    Cipher(PrivateMessage) = 2u16,
+    Welcome(Welcome) = 3u16,
+    GroupInfo(GroupInfo) = 4u16,
+    KeyPackage(KeyPackage) = 5u16,
+}
+
+impl From<PublicMessage> for MlsMessagePayload {
+    fn from(m: PublicMessage) -> Self {
+        Self::Plain(m)
+    }
+}
+
+#[cfg_attr(all(feature = "ffi", not(test)), safer_ffi_gen::ffi_type)]
+#[derive(
+    Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, MlsSize, MlsEncode, MlsDecode,
+)]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[repr(u16)]
+#[non_exhaustive]
+/// Content description of an [`MlsMessage`]
+pub enum WireFormat {
+    PublicMessage = 1u16,
+    PrivateMessage = 2u16,
+    Welcome = 3u16,
+    GroupInfo = 4u16,
+    KeyPackage = 5u16,
+}
+
+#[derive(Clone, PartialEq, MlsSize, MlsEncode, MlsDecode)]
+#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+pub(crate) struct FramedContent {
+    #[mls_codec(with = "mls_rs_codec::byte_vec")]
+    #[cfg_attr(feature = "serde", serde(with = "mls_rs_core::vec_serde"))]
+    pub group_id: Vec<u8>,
+    pub epoch: u64,
+    pub sender: Sender,
+    #[mls_codec(with = "mls_rs_codec::byte_vec")]
+    #[cfg_attr(feature = "serde", serde(with = "mls_rs_core::vec_serde"))]
+    pub authenticated_data: Vec<u8>,
+    pub content: Content,
+}
+
+impl Debug for FramedContent {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.debug_struct("FramedContent")
+            .field(
+                "group_id",
+                &mls_rs_core::debug::pretty_group_id(&self.group_id),
+            )
+            .field("epoch", &self.epoch)
+            .field("sender", &self.sender)
+            .field(
+                "authenticated_data",
+                &mls_rs_core::debug::pretty_bytes(&self.authenticated_data),
+            )
+            .field("content", &self.content)
+            .finish()
+    }
+}
+
+impl FramedContent {
+    pub fn content_type(&self) -> ContentType {
+        self.content.content_type()
+    }
+}
+
+#[cfg(test)]
+pub(crate) mod test_utils {
+    #[cfg(feature = "private_message")]
+    use crate::group::test_utils::random_bytes;
+
+    use crate::group::{AuthenticatedContent, MessageSignature};
+
+    use super::*;
+
+    use alloc::boxed::Box;
+
+    pub(crate) fn get_test_auth_content() -> AuthenticatedContent {
+        // This is not a valid commit and should not be validated
+        let commit = Commit {
+            proposals: Default::default(),
+            path: None,
+        };
+
+        AuthenticatedContent {
+            wire_format: WireFormat::PublicMessage,
+            content: FramedContent {
+                group_id: Vec::new(),
+                epoch: 0,
+                sender: Sender::Member(1),
+                authenticated_data: Vec::new(),
+                content: Content::Commit(Box::new(commit)),
+            },
+            auth: FramedContentAuthData {
+                signature: MessageSignature::empty(),
+                confirmation_tag: None,
+            },
+        }
+    }
+
+    #[cfg(feature = "private_message")]
+    pub(crate) fn get_test_ciphertext_content() -> PrivateMessageContent {
+        PrivateMessageContent {
+            content: Content::Application(random_bytes(1024).into()),
+            auth: FramedContentAuthData {
+                signature: MessageSignature::from(random_bytes(128)),
+                confirmation_tag: None,
+            },
+        }
+    }
+
+    impl AsRef<[u8]> for ApplicationData {
+        fn as_ref(&self) -> &[u8] {
+            &self.0
+        }
+    }
+}
+
+#[cfg(feature = "private_message")]
+#[cfg(test)]
+mod tests {
+    use assert_matches::assert_matches;
+
+    use crate::{
+        client::test_utils::{TEST_CIPHER_SUITE, TEST_PROTOCOL_VERSION},
+        crypto::test_utils::test_cipher_suite_provider,
+        group::{
+            framing::test_utils::get_test_ciphertext_content,
+            proposal_ref::test_utils::auth_content_from_proposal, RemoveProposal,
+        },
+    };
+
+    use super::*;
+
+    #[test]
+    fn test_mls_ciphertext_content_mls_encoding() {
+        let ciphertext_content = get_test_ciphertext_content();
+
+        let mut encoded = ciphertext_content.mls_encode_to_vec().unwrap();
+        encoded.extend_from_slice(&[0u8; 128]);
+
+        let decoded =
+            PrivateMessageContent::mls_decode(&mut &*encoded, (&ciphertext_content.content).into())
+                .unwrap();
+
+        assert_eq!(ciphertext_content, decoded);
+    }
+
+    #[test]
+    fn test_mls_ciphertext_content_non_zero_padding_error() {
+        let ciphertext_content = get_test_ciphertext_content();
+
+        let mut encoded = ciphertext_content.mls_encode_to_vec().unwrap();
+        encoded.extend_from_slice(&[1u8; 128]);
+
+        let decoded =
+            PrivateMessageContent::mls_decode(&mut &*encoded, (&ciphertext_content.content).into());
+
+        assert_matches!(decoded, Err(mls_rs_codec::Error::Custom(_)));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn proposal_ref() {
+        let cs = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+
+        let test_auth = auth_content_from_proposal(
+            Proposal::Remove(RemoveProposal {
+                to_remove: LeafIndex(0),
+            }),
+            Sender::External(0),
+        );
+
+        let expected_ref = ProposalRef::from_content(&cs, &test_auth).await.unwrap();
+
+        let test_message = MlsMessage {
+            version: TEST_PROTOCOL_VERSION,
+            payload: MlsMessagePayload::Plain(PublicMessage {
+                content: test_auth.content,
+                auth: test_auth.auth,
+                membership_tag: Some(cs.mac(&[1, 2, 3], &[1, 2, 3]).await.unwrap().into()),
+            }),
+        };
+
+        let computed_ref = test_message
+            .into_proposal_reference(&cs)
+            .await
+            .unwrap()
+            .unwrap();
+
+        assert_eq!(computed_ref, expected_ref.to_vec());
+    }
+}
diff --git a/src/group/group_info.rs b/src/group/group_info.rs
new file mode 100644
index 0000000..a5e7268
--- /dev/null
+++ b/src/group/group_info.rs
@@ -0,0 +1,95 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// Copyright by contributors to this project.
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+
+use alloc::vec::Vec;
+use core::fmt::{self, Debug};
+use mls_rs_codec::{MlsDecode, MlsEncode, MlsSize};
+use mls_rs_core::extension::ExtensionList;
+
+use crate::{signer::Signable, tree_kem::node::LeafIndex};
+
+use super::{ConfirmationTag, GroupContext};
+
+#[derive(Clone, PartialEq, MlsSize, MlsEncode, MlsDecode)]
+#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
+#[cfg_attr(
+    all(feature = "ffi", not(test)),
+    safer_ffi_gen::ffi_type(clone, opaque)
+)]
+pub struct GroupInfo {
+    pub(crate) group_context: GroupContext,
+    pub(crate) extensions: ExtensionList,
+    pub(crate) confirmation_tag: ConfirmationTag,
+    pub(crate) signer: LeafIndex,
+    #[mls_codec(with = "mls_rs_codec::byte_vec")]
+    pub(crate) signature: Vec<u8>,
+}
+
+impl Debug for GroupInfo {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.debug_struct("GroupInfo")
+            .field("group_context", &self.group_context)
+            .field("extensions", &self.extensions)
+            .field("confirmation_tag", &self.confirmation_tag)
+            .field("signer", &self.signer)
+            .field(
+                "signature",
+                &mls_rs_core::debug::pretty_bytes(&self.signature),
+            )
+            .finish()
+    }
+}
+
+#[cfg_attr(all(feature = "ffi", not(test)), ::safer_ffi_gen::safer_ffi_gen)]
+impl GroupInfo {
+    /// Group context.
+    pub fn group_context(&self) -> &GroupContext {
+        &self.group_context
+    }
+
+    /// Group info extensions (not to be confused with group context extensions),
+    /// e.g. the ratchet tree.
+    pub fn extensions(&self) -> &ExtensionList {
+        &self.extensions
+    }
+
+    /// Leaf index of the sender who generated and signed this group info.
+    pub fn sender(&self) -> u32 {
+        *self.signer
+    }
+}
+
+#[derive(MlsEncode, MlsSize)]
+struct SignableGroupInfo<'a> {
+    group_context: &'a GroupContext,
+    extensions: &'a ExtensionList,
+    confirmation_tag: &'a ConfirmationTag,
+    signer: LeafIndex,
+}
+
+impl<'a> Signable<'a> for GroupInfo {
+    const SIGN_LABEL: &'static str = "GroupInfoTBS";
+    type SigningContext = ();
+
+    fn signature(&self) -> &[u8] {
+        &self.signature
+    }
+
+    fn signable_content(
+        &self,
+        _context: &Self::SigningContext,
+    ) -> Result<Vec<u8>, mls_rs_codec::Error> {
+        SignableGroupInfo {
+            group_context: &self.group_context,
+            extensions: &self.extensions,
+            confirmation_tag: &self.confirmation_tag,
+            signer: self.signer,
+        }
+        .mls_encode_to_vec()
+    }
+
+    fn write_signature(&mut self, signature: Vec<u8>) {
+        self.signature = signature
+    }
+}
diff --git a/src/group/interop_test_vectors.rs b/src/group/interop_test_vectors.rs
new file mode 100644
index 0000000..abd82fc
--- /dev/null
+++ b/src/group/interop_test_vectors.rs
@@ -0,0 +1,9 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// Copyright by contributors to this project.
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+
+mod framing;
+mod passive_client;
+mod serialization;
+mod tree_kem;
+mod tree_modifications;
diff --git a/src/group/interop_test_vectors/framing.rs b/src/group/interop_test_vectors/framing.rs
new file mode 100644
index 0000000..30e4225
--- /dev/null
+++ b/src/group/interop_test_vectors/framing.rs
@@ -0,0 +1,461 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// Copyright by contributors to this project.
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+
+use alloc::vec;
+use alloc::vec::Vec;
+use mls_rs_codec::{MlsDecode, MlsEncode};
+use mls_rs_core::crypto::{CipherSuite, CipherSuiteProvider, SignaturePublicKey};
+
+use crate::{
+    client::test_utils::{TestClientConfig, TEST_PROTOCOL_VERSION},
+    crypto::test_utils::{test_cipher_suite_provider, try_test_cipher_suite_provider},
+    group::{
+        confirmation_tag::ConfirmationTag,
+        epoch::EpochSecrets,
+        framing::{Content, WireFormat},
+        message_processor::{EventOrContent, MessageProcessor},
+        mls_rules::EncryptionOptions,
+        padding::PaddingMode,
+        proposal::{Proposal, RemoveProposal},
+        secret_tree::test_utils::get_test_tree,
+        test_utils::{random_bytes, test_group_custom_config},
+        AuthenticatedContent, Commit, Group, GroupContext, MlsMessage, Sender,
+    },
+    mls_rules::DefaultMlsRules,
+    test_utils::is_edwards,
+    tree_kem::{leaf_node::test_utils::get_basic_test_node, node::LeafIndex},
+};
+
+const FRAMING_N_LEAVES: u32 = 2;
+
+#[derive(serde::Serialize, serde::Deserialize, Debug, Default, Clone)]
+struct FramingTestCase {
+    #[serde(flatten)]
+    pub context: InteropGroupContext,
+
+    #[serde(with = "hex::serde")]
+    pub signature_priv: Vec<u8>,
+    #[serde(with = "hex::serde")]
+    pub signature_pub: Vec<u8>,
+
+    #[serde(with = "hex::serde")]
+    pub encryption_secret: Vec<u8>,
+    #[serde(with = "hex::serde")]
+    pub sender_data_secret: Vec<u8>,
+    #[serde(with = "hex::serde")]
+    pub membership_key: Vec<u8>,
+
+    #[serde(with = "hex::serde")]
+    pub proposal: Vec<u8>,
+    #[serde(with = "hex::serde")]
+    pub proposal_priv: Vec<u8>,
+    #[serde(with = "hex::serde")]
+    pub proposal_pub: Vec<u8>,
+
+    #[serde(with = "hex::serde")]
+    pub commit: Vec<u8>,
+    #[serde(with = "hex::serde")]
+    pub commit_priv: Vec<u8>,
+    #[serde(with = "hex::serde")]
+    pub commit_pub: Vec<u8>,
+
+    #[serde(with = "hex::serde")]
+    pub application: Vec<u8>,
+    #[serde(with = "hex::serde")]
+    pub application_priv: Vec<u8>,
+}
+
+impl FramingTestCase {
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    #[cfg_attr(coverage_nightly, coverage(off))]
+    async fn random<P: CipherSuiteProvider>(cs: &P) -> Self {
+        let mut context = InteropGroupContext::random(cs);
+        context.cipher_suite = cs.cipher_suite().into();
+
+        let (mut signature_priv, signature_pub) = cs.signature_key_generate().await.unwrap();
+
+        if is_edwards(*cs.cipher_suite()) {
+            signature_priv = signature_priv[0..signature_priv.len() / 2].to_vec().into();
+        }
+
+        Self {
+            context,
+            signature_priv: signature_priv.to_vec(),
+            signature_pub: signature_pub.to_vec(),
+            encryption_secret: random_bytes(cs.kdf_extract_size()),
+            sender_data_secret: random_bytes(cs.kdf_extract_size()),
+            membership_key: random_bytes(cs.kdf_extract_size()),
+            ..Default::default()
+        }
+    }
+}
+
+#[derive(serde::Serialize, serde::Deserialize, Debug, Default, Clone)]
+pub struct InteropGroupContext {
+    pub cipher_suite: u16,
+    #[serde(with = "hex::serde")]
+    pub group_id: Vec<u8>,
+    pub epoch: u64,
+    #[serde(with = "hex::serde")]
+    pub tree_hash: Vec<u8>,
+    #[serde(with = "hex::serde")]
+    pub confirmed_transcript_hash: Vec<u8>,
+}
+
+impl InteropGroupContext {
+    #[cfg_attr(coverage_nightly, coverage(off))]
+    fn random<P: CipherSuiteProvider>(cs: &P) -> Self {
+        Self {
+            cipher_suite: cs.cipher_suite().into(),
+            group_id: random_bytes(cs.kdf_extract_size()),
+            epoch: 0x121212,
+            tree_hash: random_bytes(cs.kdf_extract_size()),
+            confirmed_transcript_hash: random_bytes(cs.kdf_extract_size()),
+        }
+    }
+}
+
+impl From<InteropGroupContext> for GroupContext {
+    #[cfg_attr(coverage_nightly, coverage(off))]
+    fn from(ctx: InteropGroupContext) -> Self {
+        Self {
+            cipher_suite: ctx.cipher_suite.into(),
+            protocol_version: TEST_PROTOCOL_VERSION,
+            group_id: ctx.group_id,
+            epoch: ctx.epoch,
+            tree_hash: ctx.tree_hash,
+            confirmed_transcript_hash: ctx.confirmed_transcript_hash.into(),
+            extensions: vec![].into(),
+        }
+    }
+}
+
+// The test vector can be found here:
+// https://github.com/mlswg/mls-implementations/blob/main/test-vectors/message-protection.json
+#[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+async fn framing_proposal() {
+    #[cfg(not(mls_build_async))]
+    let test_cases: Vec<FramingTestCase> =
+        load_test_case_json!(framing, generate_framing_test_vector());
+
+    #[cfg(mls_build_async)]
+    let test_cases: Vec<FramingTestCase> =
+        load_test_case_json!(framing, generate_framing_test_vector().await);
+
+    for test_case in test_cases.into_iter() {
+        let Some(cs) = try_test_cipher_suite_provider(test_case.context.cipher_suite) else {
+            continue;
+        };
+
+        let to_check = vec![
+            test_case.proposal_priv.clone(),
+            test_case.proposal_pub.clone(),
+        ];
+
+        // Wasm uses incompatible signature secret key format
+        #[cfg(not(target_arch = "wasm32"))]
+        let mut to_check = to_check;
+
+        #[cfg(not(target_arch = "wasm32"))]
+        for enable_encryption in [true, false] {
+            let proposal = Proposal::mls_decode(&mut &*test_case.proposal).unwrap();
+
+            let built = make_group(&test_case, true, enable_encryption, &cs)
+                .await
+                .proposal_message(proposal, vec![])
+                .await
+                .unwrap()
+                .mls_encode_to_vec()
+                .unwrap();
+
+            to_check.push(built);
+        }
+
+        let proposal = Proposal::mls_decode(&mut &*test_case.proposal).unwrap();
+
+        for message in to_check {
+            match process_message(&test_case, &message, &cs).await {
+                Content::Proposal(p) => assert_eq!(p.as_ref(), &proposal),
+                _ => panic!("received value not proposal"),
+            };
+        }
+    }
+}
+
+// The test vector can be found here:
+// https://github.com/mlswg/mls-implementations/blob/main/test-vectors/message-protection.json
+// Wasm uses incompatible signature secret key format
+#[cfg(not(target_arch = "wasm32"))]
+#[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+async fn framing_application() {
+    #[cfg(not(mls_build_async))]
+    let test_cases: Vec<FramingTestCase> =
+        load_test_case_json!(framing, generate_framing_test_vector());
+
+    #[cfg(mls_build_async)]
+    let test_cases: Vec<FramingTestCase> =
+        load_test_case_json!(framing, generate_framing_test_vector().await);
+
+    for test_case in test_cases.into_iter() {
+        let Some(cs) = try_test_cipher_suite_provider(test_case.context.cipher_suite) else {
+            continue;
+        };
+
+        let built_priv = make_group(&test_case, true, true, &cs)
+            .await
+            .encrypt_application_message(&test_case.application, vec![])
+            .await
+            .unwrap()
+            .mls_encode_to_vec()
+            .unwrap();
+
+        for message in [&test_case.application_priv, &built_priv] {
+            match process_message(&test_case, message, &cs).await {
+                Content::Application(data) => assert_eq!(data.as_ref(), &test_case.application),
+                _ => panic!("decrypted value not application data"),
+            };
+        }
+    }
+}
+
+// The test vector can be found here:
+// https://github.com/mlswg/mls-implementations/blob/main/test-vectors/message-protection.json
+#[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+async fn framing_commit() {
+    #[cfg(not(mls_build_async))]
+    let test_cases: Vec<FramingTestCase> =
+        load_test_case_json!(framing, generate_framing_test_vector());
+
+    #[cfg(mls_build_async)]
+    let test_cases: Vec<FramingTestCase> =
+        load_test_case_json!(framing, generate_framing_test_vector().await);
+
+    for test_case in test_cases.into_iter() {
+        let Some(cs) = try_test_cipher_suite_provider(test_case.context.cipher_suite) else {
+            continue;
+        };
+
+        let commit = Commit::mls_decode(&mut &*test_case.commit).unwrap();
+
+        let to_check = vec![test_case.commit_priv.clone(), test_case.commit_pub.clone()];
+
+        // Wasm uses incompatible signature secret key format
+        #[cfg(not(target_arch = "wasm32"))]
+        let to_check = {
+            let mut to_check = to_check;
+
+            let mut signature_priv = test_case.signature_priv.clone();
+
+            if is_edwards(test_case.context.cipher_suite) {
+                signature_priv.extend(test_case.signature_pub.iter());
+            }
+
+            let mut auth_content = AuthenticatedContent::new_signed(
+                &cs,
+                &test_case.context.clone().into(),
+                Sender::Member(1),
+                Content::Commit(alloc::boxed::Box::new(commit.clone())),
+                &signature_priv.into(),
+                WireFormat::PublicMessage,
+                vec![],
+            )
+            .await
+            .unwrap();
+
+            auth_content.auth.confirmation_tag = Some(ConfirmationTag::empty(&cs).await);
+
+            for enable_encryption in [true, false] {
+                let built = make_group(&test_case, true, enable_encryption, &cs)
+                    .await
+                    .format_for_wire(auth_content.clone())
+                    .await
+                    .unwrap()
+                    .mls_encode_to_vec()
+                    .unwrap();
+
+                to_check.push(built);
+            }
+
+            to_check
+        };
+
+        for message in to_check {
+            match process_message(&test_case, &message, &cs).await {
+                Content::Commit(c) => assert_eq!(&*c, &commit),
+                _ => panic!("received value not commit"),
+            };
+        }
+        let commit = Commit::mls_decode(&mut &*test_case.commit).unwrap();
+
+        match process_message(&test_case, &test_case.commit_priv.clone(), &cs).await {
+            Content::Commit(c) => assert_eq!(&*c, &commit),
+            _ => panic!("received value not commit"),
+        };
+    }
+}
+
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+#[cfg_attr(coverage_nightly, coverage(off))]
+async fn generate_framing_test_vector() -> Vec<FramingTestCase> {
+    let mut test_vector = vec![];
+
+    for cs in CipherSuite::all() {
+        let cs = test_cipher_suite_provider(cs);
+
+        let mut test_case = FramingTestCase::random(&cs).await;
+
+        // Generate private application message
+        test_case.application = cs.random_bytes_vec(42).unwrap();
+
+        let application_priv = make_group(&test_case, true, true, &cs)
+            .await
+            .encrypt_application_message(&test_case.application, vec![])
+            .await
+            .unwrap();
+
+        test_case.application_priv = application_priv.mls_encode_to_vec().unwrap();
+
+        // Generate private and public proposal message
+        let proposal = Proposal::Remove(RemoveProposal {
+            to_remove: LeafIndex(2),
+        });
+
+        test_case.proposal = proposal.mls_encode_to_vec().unwrap();
+
+        let mut group = make_group(&test_case, true, false, &cs).await;
+        let proposal_pub = group.proposal_message(proposal.clone(), vec![]).await;
+        test_case.proposal_pub = proposal_pub.unwrap().mls_encode_to_vec().unwrap();
+
+        let mut group = make_group(&test_case, true, true, &cs).await;
+        let proposal_priv = group.proposal_message(proposal, vec![]).await.unwrap();
+        test_case.proposal_priv = proposal_priv.mls_encode_to_vec().unwrap();
+
+        // Generate private and public commit message
+        let commit = Commit {
+            proposals: vec![],
+            path: None,
+        };
+
+        test_case.commit = commit.mls_encode_to_vec().unwrap();
+
+        let mut auth_content = AuthenticatedContent::new_signed(
+            &cs,
+            group.context(),
+            Sender::Member(1),
+            Content::Commit(alloc::boxed::Box::new(commit.clone())),
+            &group.signer,
+            WireFormat::PublicMessage,
+            vec![],
+        )
+        .await
+        .unwrap();
+
+        auth_content.auth.confirmation_tag = Some(ConfirmationTag::empty(&cs).await);
+
+        let mut group = make_group(&test_case, true, false, &cs).await;
+        let commit_pub = group.format_for_wire(auth_content.clone()).await.unwrap();
+        test_case.commit_pub = commit_pub.mls_encode_to_vec().unwrap();
+
+        let mut auth_content = AuthenticatedContent::new_signed(
+            &cs,
+            group.context(),
+            Sender::Member(1),
+            Content::Commit(alloc::boxed::Box::new(commit)),
+            &group.signer,
+            WireFormat::PrivateMessage,
+            vec![],
+        )
+        .await
+        .unwrap();
+
+        auth_content.auth.confirmation_tag = Some(ConfirmationTag::empty(&cs).await);
+
+        let mut group = make_group(&test_case, true, true, &cs).await;
+        let commit_priv = group.format_for_wire(auth_content.clone()).await.unwrap();
+        test_case.commit_priv = commit_priv.mls_encode_to_vec().unwrap();
+
+        test_vector.push(test_case);
+    }
+
+    test_vector
+}
+
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+async fn make_group<P: CipherSuiteProvider>(
+    test_case: &FramingTestCase,
+    for_send: bool,
+    control_encryption_enabled: bool,
+    cs: &P,
+) -> Group<TestClientConfig> {
+    let mut group =
+        test_group_custom_config(
+            TEST_PROTOCOL_VERSION,
+            test_case.context.cipher_suite.into(),
+            |b| {
+                b.mls_rules(DefaultMlsRules::default().with_encryption_options(
+                    EncryptionOptions::new(control_encryption_enabled, PaddingMode::None),
+                ))
+            },
+        )
+        .await
+        .group;
+
+    // Add a leaf for the sender. It will get index 1.
+    let mut leaf = get_basic_test_node(cs.cipher_suite(), "leaf").await;
+
+    leaf.signing_identity.signature_key = SignaturePublicKey::from(test_case.signature_pub.clone());
+
+    group
+        .state
+        .public_tree
+        .add_leaves(vec![leaf], &group.config.0.identity_provider, cs)
+        .await
+        .unwrap();
+
+    // Convince the group that their index is 1 if they send or 0 if they receive.
+    group.private_tree.self_index = LeafIndex(if for_send { 1 } else { 0 });
+
+    // Convince the group that their signing key is the one from the test case
+    let mut signature_priv = test_case.signature_priv.clone();
+
+    if is_edwards(test_case.context.cipher_suite) {
+        signature_priv.extend(test_case.signature_pub.iter());
+    }
+
+    group.signer = signature_priv.into();
+
+    // Set the group context and secrets
+    let context = GroupContext::from(test_case.context.clone());
+    let secret_tree = get_test_tree(test_case.encryption_secret.clone(), FRAMING_N_LEAVES);
+
+    let secrets = EpochSecrets {
+        secret_tree,
+        resumption_secret: vec![0_u8; cs.kdf_extract_size()].into(),
+        sender_data_secret: test_case.sender_data_secret.clone().into(),
+    };
+
+    group.epoch_secrets = secrets;
+    group.state.context = context;
+    let membership_key = test_case.membership_key.clone();
+    group.key_schedule.set_membership_key(membership_key);
+
+    group
+}
+
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+async fn process_message<P: CipherSuiteProvider>(
+    test_case: &FramingTestCase,
+    message: &[u8],
+    cs: &P,
+) -> Content {
+    // Enabling encryption doesn't matter for processing
+    let mut group = make_group(test_case, false, true, cs).await;
+    let message = MlsMessage::mls_decode(&mut &*message).unwrap();
+    let evt_or_cont = group.get_event_from_incoming_message(message);
+
+    match evt_or_cont.await.unwrap() {
+        EventOrContent::Content(content) => content.content.content,
+        EventOrContent::Event(_) => panic!("expected content, got event"),
+    }
+}
diff --git a/src/group/interop_test_vectors/passive_client.rs b/src/group/interop_test_vectors/passive_client.rs
new file mode 100644
index 0000000..29588ed
--- /dev/null
+++ b/src/group/interop_test_vectors/passive_client.rs
@@ -0,0 +1,732 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// Copyright by contributors to this project.
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+
+use alloc::vec;
+use alloc::vec::Vec;
+
+use itertools::Itertools;
+use mls_rs_core::{
+    crypto::{CipherSuite, CipherSuiteProvider, CryptoProvider},
+    identity::SigningIdentity,
+    protocol_version::ProtocolVersion,
+    psk::ExternalPskId,
+    time::MlsTime,
+};
+use rand::{seq::IteratorRandom, Rng, SeedableRng};
+
+use crate::{
+    client_builder::{ClientBuilder, MlsConfig},
+    crypto::test_utils::TestCryptoProvider,
+    group::{ClientConfig, CommitBuilder, ExportedTree},
+    identity::basic::BasicIdentityProvider,
+    key_package::KeyPackageGeneration,
+    mls_rules::CommitOptions,
+    storage_provider::in_memory::InMemoryKeyPackageStorage,
+    test_utils::{
+        all_process_message, generate_basic_client, get_test_basic_credential, get_test_groups,
+        make_test_ext_psk, TEST_EXT_PSK_ID,
+    },
+    tree_kem::Lifetime,
+    Client, Group, MlsMessage,
+};
+
+const VERSION: ProtocolVersion = ProtocolVersion::MLS_10;
+
+const ETERNAL_LIFETIME: Lifetime = Lifetime {
+    not_before: 0,
+    not_after: u64::MAX,
+};
+
+#[derive(serde::Serialize, serde::Deserialize, Debug, Default, Clone)]
+pub struct TestCase {
+    pub cipher_suite: u16,
+
+    pub external_psks: Vec<TestExternalPsk>,
+    #[serde(with = "hex::serde")]
+    pub key_package: Vec<u8>,
+    #[serde(with = "hex::serde")]
+    pub signature_priv: Vec<u8>,
+    #[serde(with = "hex::serde")]
+    pub encryption_priv: Vec<u8>,
+    #[serde(with = "hex::serde")]
+    pub init_priv: Vec<u8>,
+
+    #[serde(with = "hex::serde")]
+    pub welcome: Vec<u8>,
+    pub ratchet_tree: Option<TestRatchetTree>,
+    #[serde(with = "hex::serde")]
+    pub initial_epoch_authenticator: Vec<u8>,
+
+    pub epochs: Vec<TestEpoch>,
+}
+
+#[derive(serde::Serialize, serde::Deserialize, Debug, Default, Clone)]
+pub struct TestExternalPsk {
+    #[serde(with = "hex::serde")]
+    pub psk_id: Vec<u8>,
+    #[serde(with = "hex::serde")]
+    pub psk: Vec<u8>,
+}
+
+#[derive(serde::Serialize, serde::Deserialize, Debug, Default, Clone)]
+pub struct TestEpoch {
+    pub proposals: Vec<TestMlsMessage>,
+    #[serde(with = "hex::serde")]
+    pub commit: Vec<u8>,
+    #[serde(with = "hex::serde")]
+    pub epoch_authenticator: Vec<u8>,
+}
+
+#[derive(serde::Serialize, serde::Deserialize, Debug, Default, Clone)]
+pub struct TestMlsMessage(#[serde(with = "hex::serde")] pub Vec<u8>);
+
+#[derive(serde::Serialize, serde::Deserialize, Debug, Default, Clone)]
+pub struct TestRatchetTree(#[serde(with = "hex::serde")] pub Vec<u8>);
+
+impl TestEpoch {
+    #[cfg_attr(coverage_nightly, coverage(off))]
+    pub fn new(
+        proposals: Vec<MlsMessage>,
+        commit: &MlsMessage,
+        epoch_authenticator: Vec<u8>,
+    ) -> Self {
+        let proposals = proposals
+            .into_iter()
+            .map(|p| TestMlsMessage(p.to_bytes().unwrap()))
+            .collect();
+
+        Self {
+            proposals,
+            commit: commit.to_bytes().unwrap(),
+            epoch_authenticator,
+        }
+    }
+}
+
+#[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+async fn interop_passive_client() {
+    // Test vectors can be found here:
+    // * https://github.com/mlswg/mls-implementations/blob/main/test-vectors/passive-client-welcome.json
+    // * https://github.com/mlswg/mls-implementations/blob/main/test-vectors/passive-client-handle-commit.json
+    // * https://github.com/mlswg/mls-implementations/blob/main/test-vectors/passive-client-random.json
+
+    #[cfg(mls_build_async)]
+    let (test_cases_wel, test_cases_com, test_cases_rand) = {
+        let test_cases_wel: Vec<TestCase> = load_test_case_json!(
+            interop_passive_client_welcome,
+            generate_passive_client_welcome_tests().await
+        );
+
+        let test_cases_com: Vec<TestCase> = load_test_case_json!(
+            interop_passive_client_handle_commit,
+            generate_passive_client_proposal_tests().await
+        );
+
+        let test_cases_rand: Vec<TestCase> = load_test_case_json!(
+            interop_passive_client_random,
+            generate_passive_client_random_tests().await
+        );
+
+        (test_cases_wel, test_cases_com, test_cases_rand)
+    };
+
+    #[cfg(not(mls_build_async))]
+    let (test_cases_wel, test_cases_com, test_cases_rand) = {
+        let test_cases_wel: Vec<TestCase> = load_test_case_json!(
+            interop_passive_client_welcome,
+            generate_passive_client_welcome_tests()
+        );
+
+        let test_cases_com: Vec<TestCase> = load_test_case_json!(
+            interop_passive_client_handle_commit,
+            generate_passive_client_proposal_tests()
+        );
+
+        let test_cases_rand: Vec<TestCase> = load_test_case_json!(
+            interop_passive_client_random,
+            generate_passive_client_random_tests()
+        );
+
+        (test_cases_wel, test_cases_com, test_cases_rand)
+    };
+
+    for test_case in vec![]
+        .into_iter()
+        .chain(test_cases_com)
+        .chain(test_cases_wel)
+        .chain(test_cases_rand)
+    {
+        let crypto_provider = TestCryptoProvider::new();
+        let Some(cs) = crypto_provider.cipher_suite_provider(test_case.cipher_suite.into()) else {
+            continue;
+        };
+
+        let message = MlsMessage::from_bytes(&test_case.key_package).unwrap();
+        let key_package = message.into_key_package().unwrap();
+        let id = key_package.leaf_node.signing_identity.clone();
+        let key = test_case.signature_priv.clone().into();
+
+        let mut client_builder = ClientBuilder::new()
+            .crypto_provider(crypto_provider)
+            .identity_provider(BasicIdentityProvider::new());
+
+        for psk in test_case.external_psks {
+            client_builder = client_builder.psk(ExternalPskId::new(psk.psk_id), psk.psk.into());
+        }
+
+        let client = client_builder
+            .signing_identity(id, key, cs.cipher_suite())
+            .build();
+
+        let key_pckg_gen = KeyPackageGeneration {
+            reference: key_package.to_reference(&cs).await.unwrap(),
+            key_package,
+            init_secret_key: test_case.init_priv.into(),
+            leaf_node_secret_key: test_case.encryption_priv.into(),
+        };
+
+        let (id, pkg) = key_pckg_gen.to_storage().unwrap();
+        client.config.key_package_repo().insert(id, pkg);
+
+        let welcome = MlsMessage::from_bytes(&test_case.welcome).unwrap();
+
+        let tree = test_case
+            .ratchet_tree
+            .map(|t| ExportedTree::from_bytes(&t.0).unwrap());
+
+        let (mut group, _info) = client.join_group(tree, &welcome).await.unwrap();
+
+        assert_eq!(
+            group.epoch_authenticator().unwrap().to_vec(),
+            test_case.initial_epoch_authenticator
+        );
+
+        for epoch in test_case.epochs {
+            for proposal in epoch.proposals.iter() {
+                let message = MlsMessage::from_bytes(&proposal.0).unwrap();
+
+                group
+                    .process_incoming_message_with_time(message, MlsTime::now())
+                    .await
+                    .unwrap();
+            }
+
+            let message = MlsMessage::from_bytes(&epoch.commit).unwrap();
+
+            group
+                .process_incoming_message_with_time(message, MlsTime::now())
+                .await
+                .unwrap();
+
+            assert_eq!(
+                epoch.epoch_authenticator,
+                group.epoch_authenticator().unwrap().to_vec()
+            );
+        }
+    }
+}
+
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+#[cfg_attr(coverage_nightly, coverage(off))]
+async fn invite_passive_client<P: CipherSuiteProvider>(
+    groups: &mut [Group<impl MlsConfig>],
+    with_psk: bool,
+    cs: &P,
+) -> TestCase {
+    let crypto_provider = TestCryptoProvider::new();
+
+    let (secret_key, public_key) = cs.signature_key_generate().await.unwrap();
+    let credential = get_test_basic_credential(b"Arnold".to_vec());
+    let identity = SigningIdentity::new(credential, public_key);
+    let key_package_repo = InMemoryKeyPackageStorage::new();
+
+    let client = ClientBuilder::new()
+        .crypto_provider(crypto_provider)
+        .identity_provider(BasicIdentityProvider::new())
+        .key_package_repo(key_package_repo.clone())
+        .key_package_lifetime(ETERNAL_LIFETIME.not_after - ETERNAL_LIFETIME.not_before)
+        .key_package_not_before(ETERNAL_LIFETIME.not_before)
+        .signing_identity(identity.clone(), secret_key.clone(), cs.cipher_suite())
+        .build();
+
+    let key_pckg = client.generate_key_package_message().await.unwrap();
+
+    let (_, key_pckg_secrets) = key_package_repo.key_packages()[0].clone();
+
+    let mut commit_builder = groups[0]
+        .commit_builder()
+        .add_member(key_pckg.clone())
+        .unwrap();
+
+    if with_psk {
+        commit_builder = commit_builder
+            .add_external_psk(ExternalPskId::new(TEST_EXT_PSK_ID.to_vec()))
+            .unwrap();
+    }
+
+    let commit = commit_builder.build().await.unwrap();
+
+    all_process_message(groups, &commit.commit_message, 0, true).await;
+
+    let external_psk = TestExternalPsk {
+        psk_id: TEST_EXT_PSK_ID.to_vec(),
+        psk: make_test_ext_psk(),
+    };
+
+    TestCase {
+        cipher_suite: cs.cipher_suite().into(),
+        key_package: key_pckg.to_bytes().unwrap(),
+        encryption_priv: key_pckg_secrets.leaf_node_key.to_vec(),
+        init_priv: key_pckg_secrets.init_key.to_vec(),
+        welcome: commit.welcome_messages[0].to_bytes().unwrap(),
+        initial_epoch_authenticator: groups[0].epoch_authenticator().unwrap().to_vec(),
+        epochs: vec![],
+        signature_priv: secret_key.to_vec(),
+        external_psks: if with_psk { vec![external_psk] } else { vec![] },
+        ratchet_tree: None,
+    }
+}
+
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+#[cfg_attr(coverage_nightly, coverage(off))]
+pub async fn generate_passive_client_proposal_tests() -> Vec<TestCase> {
+    let mut test_cases: Vec<TestCase> = vec![];
+
+    for cs in CipherSuite::all() {
+        let crypto_provider = TestCryptoProvider::new();
+        let Some(cs) = crypto_provider.cipher_suite_provider(cs) else {
+            continue;
+        };
+
+        let mut groups =
+            get_test_groups(VERSION, cs.cipher_suite(), 7, None, false, &crypto_provider).await;
+
+        let mut partial_test_case = invite_passive_client(&mut groups, true, &cs).await;
+
+        // Create a new epoch s.t. the passive member can process resumption PSK from the current one
+        let commit = groups[0].commit(vec![]).await.unwrap();
+        all_process_message(&mut groups, &commit.commit_message, 0, true).await;
+
+        partial_test_case.epochs.push(TestEpoch::new(
+            vec![],
+            &commit.commit_message,
+            groups[0].epoch_authenticator().unwrap().to_vec(),
+        ));
+
+        let psk = ExternalPskId::new(TEST_EXT_PSK_ID.to_vec());
+        let key_pckg = create_key_package(cs.cipher_suite()).await;
+
+        // Create by value proposals
+        let test_case = commit_by_value(
+            &mut groups[3].clone(),
+            |b| b.add_member(key_pckg.clone()).unwrap(),
+            partial_test_case.clone(),
+        )
+        .await;
+
+        test_cases.push(test_case);
+
+        let test_case = commit_by_value(
+            &mut groups[3].clone(),
+            |b| b.remove_member(5).unwrap(),
+            partial_test_case.clone(),
+        )
+        .await;
+
+        test_cases.push(test_case);
+
+        let test_case = commit_by_value(
+            &mut groups[1].clone(),
+            |b| b.add_external_psk(psk.clone()).unwrap(),
+            partial_test_case.clone(),
+        )
+        .await;
+
+        test_cases.push(test_case);
+
+        let test_case = commit_by_value(
+            &mut groups[5].clone(),
+            |b| b.add_resumption_psk(groups[1].current_epoch() - 1).unwrap(),
+            partial_test_case.clone(),
+        )
+        .await;
+
+        test_cases.push(test_case);
+
+        let test_case = commit_by_value(
+            &mut groups[2].clone(),
+            |b| b.set_group_context_ext(Default::default()).unwrap(),
+            partial_test_case.clone(),
+        )
+        .await;
+
+        test_cases.push(test_case);
+
+        let test_case = commit_by_value(
+            &mut groups[3].clone(),
+            |b| {
+                b.add_member(key_pckg)
+                    .unwrap()
+                    .remove_member(5)
+                    .unwrap()
+                    .add_external_psk(psk.clone())
+                    .unwrap()
+                    .add_resumption_psk(groups[4].current_epoch() - 1)
+                    .unwrap()
+                    .set_group_context_ext(Default::default())
+                    .unwrap()
+            },
+            partial_test_case.clone(),
+        )
+        .await;
+
+        test_cases.push(test_case);
+
+        // Create by reference proposals
+        let add = groups[0]
+            .propose_add(create_key_package(cs.cipher_suite()).await, vec![])
+            .await
+            .unwrap();
+
+        let add = (add, 0);
+
+        let update = (groups[1].propose_update(vec![]).await.unwrap(), 1);
+        let remove = (groups[2].propose_remove(2, vec![]).await.unwrap(), 2);
+
+        let ext_psk = groups[3]
+            .propose_external_psk(psk.clone(), vec![])
+            .await
+            .unwrap();
+
+        let ext_psk = (ext_psk, 3);
+
+        let last_ep = groups[3].current_epoch() - 1;
+
+        let res_psk = groups[3]
+            .propose_resumption_psk(last_ep, vec![])
+            .await
+            .unwrap();
+
+        let res_psk = (res_psk, 3);
+
+        let grp_ext = groups[4]
+            .propose_group_context_extensions(Default::default(), vec![])
+            .await
+            .unwrap();
+
+        let grp_ext = (grp_ext, 4);
+
+        let proposals = [add, update, remove, ext_psk, res_psk, grp_ext];
+
+        for (p, sender) in &proposals {
+            let mut groups = groups.clone();
+
+            all_process_message(&mut groups, p, *sender, false).await;
+
+            let commit = groups[5].commit(vec![]).await.unwrap().commit_message;
+
+            groups[5].apply_pending_commit().await.unwrap();
+            let auth = groups[5].epoch_authenticator().unwrap().to_vec();
+
+            let mut test_case = partial_test_case.clone();
+            let epoch = TestEpoch::new(vec![p.clone()], &commit, auth);
+            test_case.epochs.push(epoch);
+
+            test_cases.push(test_case);
+        }
+
+        let mut group = groups[4].clone();
+
+        for (p, _) in proposals.iter().filter(|(_, i)| *i != 4) {
+            group.process_incoming_message(p.clone()).await.unwrap();
+        }
+
+        let commit = group.commit(vec![]).await.unwrap().commit_message;
+        group.apply_pending_commit().await.unwrap();
+        let auth = group.epoch_authenticator().unwrap().to_vec();
+        let mut test_case = partial_test_case.clone();
+        let proposals = proposals.into_iter().map(|(p, _)| p).collect();
+        let epoch = TestEpoch::new(proposals, &commit, auth);
+        test_case.epochs.push(epoch);
+        test_cases.push(test_case);
+    }
+
+    test_cases
+}
+
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+#[cfg_attr(coverage_nightly, coverage(off))]
+async fn commit_by_value<F, C: MlsConfig>(
+    group: &mut Group<C>,
+    proposal_adder: F,
+    partial_test_case: TestCase,
+) -> TestCase
+where
+    F: FnOnce(CommitBuilder<C>) -> CommitBuilder<C>,
+{
+    let builder = proposal_adder(group.commit_builder());
+    let commit = builder.build().await.unwrap().commit_message;
+    group.apply_pending_commit().await.unwrap();
+    let auth = group.epoch_authenticator().unwrap().to_vec();
+    let epoch = TestEpoch::new(vec![], &commit, auth);
+    let mut test_case = partial_test_case;
+    test_case.epochs.push(epoch);
+    test_case
+}
+
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+#[cfg_attr(coverage_nightly, coverage(off))]
+async fn create_key_package(cs: CipherSuite) -> MlsMessage {
+    let client = generate_basic_client(
+        cs,
+        VERSION,
+        0xbeef,
+        None,
+        false,
+        &TestCryptoProvider::new(),
+        Some(ETERNAL_LIFETIME),
+    )
+    .await;
+
+    client.generate_key_package_message().await.unwrap()
+}
+
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+#[cfg_attr(coverage_nightly, coverage(off))]
+pub async fn generate_passive_client_welcome_tests() -> Vec<TestCase> {
+    let mut test_cases: Vec<TestCase> = vec![];
+
+    for cs in CipherSuite::all() {
+        let crypto_provider = TestCryptoProvider::new();
+        let Some(cs) = crypto_provider.cipher_suite_provider(cs) else {
+            continue;
+        };
+
+        for with_tree_in_extension in [true, false] {
+            for (with_psk, with_path) in [false, true].into_iter().cartesian_product([true, false])
+            {
+                let options = CommitOptions::new()
+                    .with_path_required(with_path)
+                    .with_ratchet_tree_extension(with_tree_in_extension);
+
+                let mut groups = get_test_groups(
+                    VERSION,
+                    cs.cipher_suite(),
+                    16,
+                    Some(options),
+                    false,
+                    &crypto_provider,
+                )
+                .await;
+
+                // Remove a member s.t. the passive member joins in their place
+                let proposal = groups[0].propose_remove(7, vec![]).await.unwrap();
+                all_process_message(&mut groups, &proposal, 0, false).await;
+
+                let mut test_case = invite_passive_client(&mut groups, with_psk, &cs).await;
+
+                if !with_tree_in_extension {
+                    let tree = groups[0].export_tree().to_bytes().unwrap();
+                    test_case.ratchet_tree = Some(TestRatchetTree(tree));
+                }
+
+                test_cases.push(test_case);
+            }
+        }
+    }
+
+    test_cases
+}
+
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+#[cfg_attr(coverage_nightly, coverage(off))]
+pub async fn generate_passive_client_random_tests() -> Vec<TestCase> {
+    let mut test_cases: Vec<TestCase> = vec![];
+
+    for cs in CipherSuite::all() {
+        let crypto = TestCryptoProvider::new();
+        let Some(csp) = crypto.cipher_suite_provider(cs) else {
+            continue;
+        };
+
+        let creator =
+            generate_basic_client(cs, VERSION, 0, None, false, &crypto, Some(ETERNAL_LIFETIME))
+                .await;
+
+        let creator_group = creator.create_group(Default::default()).await.unwrap();
+
+        let mut groups = vec![creator_group];
+
+        let mut new_clients = Vec::new();
+
+        for i in 0..10 {
+            new_clients.push(
+                generate_basic_client(
+                    cs,
+                    VERSION,
+                    i + 1,
+                    None,
+                    false,
+                    &crypto,
+                    Some(ETERNAL_LIFETIME),
+                )
+                .await,
+            )
+        }
+
+        add_random_members(0, &mut groups, new_clients, None).await;
+
+        let mut test_case = invite_passive_client(&mut groups, false, &csp).await;
+
+        let passive_client_index = 11;
+
+        let seed: <rand::rngs::StdRng as SeedableRng>::Seed = rand::random();
+        let mut rng = rand::rngs::StdRng::from_seed(seed);
+        #[cfg(feature = "std")]
+        println!("generating random commits for seed {}", hex::encode(seed));
+
+        let mut next_free_idx = 11;
+        for _ in 0..100 {
+            // We keep the passive client and another member to send
+            let num_removed = rng.gen_range(0..groups.len() - 2);
+            let num_added = rng.gen_range(1..30);
+
+            let mut members = (0..groups.len())
+                .filter(|i| groups[*i].current_member_index() != passive_client_index)
+                .choose_multiple(&mut rng, num_removed + 1);
+
+            let sender = members.pop().unwrap();
+
+            remove_members(members, sender, &mut groups, Some(&mut test_case)).await;
+
+            let sender = (0..groups.len())
+                .filter(|i| groups[*i].current_member_index() != passive_client_index)
+                .choose(&mut rng)
+                .unwrap();
+
+            let mut new_clients = Vec::new();
+
+            for i in 0..num_added {
+                new_clients.push(
+                    generate_basic_client(
+                        cs,
+                        VERSION,
+                        next_free_idx + i,
+                        None,
+                        false,
+                        &crypto,
+                        Some(ETERNAL_LIFETIME),
+                    )
+                    .await,
+                );
+            }
+
+            add_random_members(sender, &mut groups, new_clients, Some(&mut test_case)).await;
+
+            next_free_idx += num_added;
+        }
+
+        test_cases.push(test_case);
+    }
+
+    test_cases
+}
+
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+#[cfg_attr(coverage_nightly, coverage(off))]
+pub async fn add_random_members<C: MlsConfig>(
+    committer: usize,
+    groups: &mut Vec<Group<C>>,
+    clients: Vec<Client<C>>,
+    test_case: Option<&mut TestCase>,
+) {
+    let committer_index = groups[committer].current_member_index() as usize;
+
+    let mut key_packages = Vec::new();
+
+    for client in &clients {
+        let key_package = client.generate_key_package_message().await.unwrap();
+        key_packages.push(key_package);
+    }
+
+    let mut add_proposals = Vec::new();
+
+    let committer_group = &mut groups[committer];
+
+    for key_package in key_packages {
+        add_proposals.push(
+            committer_group
+                .propose_add(key_package, vec![])
+                .await
+                .unwrap(),
+        );
+    }
+
+    for p in &add_proposals {
+        all_process_message(groups, p, committer_index, false).await;
+    }
+
+    let commit_output = groups[committer].commit(vec![]).await.unwrap();
+
+    all_process_message(groups, &commit_output.commit_message, committer_index, true).await;
+
+    let auth = groups[committer].epoch_authenticator().unwrap().to_vec();
+    let epoch = TestEpoch::new(add_proposals, &commit_output.commit_message, auth);
+
+    if let Some(tc) = test_case {
+        tc.epochs.push(epoch)
+    };
+
+    let tree_data = groups[committer].export_tree().into_owned();
+
+    for client in &clients {
+        let commit = commit_output.welcome_messages[0].clone();
+
+        let group = client
+            .join_group(Some(tree_data.clone()), &commit)
+            .await
+            .unwrap()
+            .0;
+
+        groups.push(group);
+    }
+}
+
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+#[cfg_attr(coverage_nightly, coverage(off))]
+pub async fn remove_members<C: MlsConfig>(
+    removed_members: Vec<usize>,
+    committer: usize,
+    groups: &mut Vec<Group<C>>,
+    test_case: Option<&mut TestCase>,
+) {
+    let remove_indexes = removed_members
+        .iter()
+        .map(|removed| groups[*removed].current_member_index())
+        .collect::<Vec<u32>>();
+
+    let mut commit_builder = groups[committer].commit_builder();
+
+    for index in remove_indexes {
+        commit_builder = commit_builder.remove_member(index).unwrap();
+    }
+
+    let commit = commit_builder.build().await.unwrap().commit_message;
+    let committer_index = groups[committer].current_member_index() as usize;
+    all_process_message(groups, &commit, committer_index, true).await;
+
+    let auth = groups[committer].epoch_authenticator().unwrap().to_vec();
+    let epoch = TestEpoch::new(vec![], &commit, auth);
+
+    if let Some(tc) = test_case {
+        tc.epochs.push(epoch)
+    };
+
+    let mut index = 0;
+
+    groups.retain(|_| {
+        index += 1;
+        !(removed_members.contains(&(index - 1)))
+    });
+}
diff --git a/src/group/interop_test_vectors/serialization.rs b/src/group/interop_test_vectors/serialization.rs
new file mode 100644
index 0000000..cbaf6fa
--- /dev/null
+++ b/src/group/interop_test_vectors/serialization.rs
@@ -0,0 +1,169 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// Copyright by contributors to this project.
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+
+use alloc::vec::Vec;
+use mls_rs_codec::{MlsDecode, MlsEncode};
+
+use mls_rs_core::extension::ExtensionList;
+
+use crate::{
+    group::{
+        framing::ContentType,
+        proposal::{
+            AddProposal, ExternalInit, PreSharedKeyProposal, ReInitProposal, RemoveProposal,
+            UpdateProposal,
+        },
+        Commit, GroupSecrets, MlsMessage,
+    },
+    tree_kem::node::NodeVec,
+};
+
+#[derive(serde::Serialize, serde::Deserialize, Debug, Default, Clone)]
+struct TestCase {
+    #[serde(with = "hex::serde")]
+    mls_welcome: Vec<u8>,
+    #[serde(with = "hex::serde")]
+    mls_group_info: Vec<u8>,
+    #[serde(with = "hex::serde")]
+    mls_key_package: Vec<u8>,
+
+    #[serde(with = "hex::serde")]
+    ratchet_tree: Vec<u8>,
+    #[serde(with = "hex::serde")]
+    group_secrets: Vec<u8>,
+
+    #[serde(with = "hex::serde")]
+    add_proposal: Vec<u8>,
+    #[serde(with = "hex::serde")]
+    update_proposal: Vec<u8>,
+    #[serde(with = "hex::serde")]
+    remove_proposal: Vec<u8>,
+    #[serde(with = "hex::serde")]
+    pre_shared_key_proposal: Vec<u8>,
+    #[serde(with = "hex::serde")]
+    re_init_proposal: Vec<u8>,
+    #[serde(with = "hex::serde")]
+    external_init_proposal: Vec<u8>,
+    #[serde(with = "hex::serde")]
+    group_context_extensions_proposal: Vec<u8>,
+
+    #[serde(with = "hex::serde")]
+    commit: Vec<u8>,
+
+    #[serde(with = "hex::serde")]
+    public_message_application: Vec<u8>,
+    #[serde(with = "hex::serde")]
+    public_message_proposal: Vec<u8>,
+    #[serde(with = "hex::serde")]
+    public_message_commit: Vec<u8>,
+    #[serde(with = "hex::serde")]
+    private_message: Vec<u8>,
+}
+
+// The test vector can be found here:
+// https://github.com/mlswg/mls-implementations/blob/main/test-vectors/messages.json
+#[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+async fn serialization() {
+    let test_cases: Vec<TestCase> = load_test_case_json!(serialization, Vec::<TestCase>::new());
+
+    for test_case in test_cases.into_iter() {
+        let message = MlsMessage::from_bytes(&test_case.mls_welcome).unwrap();
+        message.clone().into_welcome().unwrap();
+        assert_eq!(&message.to_bytes().unwrap(), &test_case.mls_welcome);
+
+        let message = MlsMessage::from_bytes(&test_case.mls_group_info).unwrap();
+        message.clone().into_group_info().unwrap();
+        assert_eq!(&message.to_bytes().unwrap(), &test_case.mls_group_info);
+
+        let message = MlsMessage::from_bytes(&test_case.mls_key_package).unwrap();
+        message.clone().into_key_package().unwrap();
+        assert_eq!(&message.to_bytes().unwrap(), &test_case.mls_key_package);
+
+        let tree = NodeVec::mls_decode(&mut &*test_case.ratchet_tree).unwrap();
+
+        assert_eq!(&tree.mls_encode_to_vec().unwrap(), &test_case.ratchet_tree);
+
+        let secs = GroupSecrets::mls_decode(&mut &*test_case.group_secrets).unwrap();
+
+        assert_eq!(&secs.mls_encode_to_vec().unwrap(), &test_case.group_secrets);
+
+        let proposal = AddProposal::mls_decode(&mut &*test_case.add_proposal).unwrap();
+
+        assert_eq!(
+            &proposal.mls_encode_to_vec().unwrap(),
+            &test_case.add_proposal
+        );
+
+        let proposal = UpdateProposal::mls_decode(&mut &*test_case.update_proposal).unwrap();
+
+        assert_eq!(
+            &proposal.mls_encode_to_vec().unwrap(),
+            &test_case.update_proposal
+        );
+
+        let proposal = RemoveProposal::mls_decode(&mut &*test_case.remove_proposal).unwrap();
+
+        assert_eq!(
+            &proposal.mls_encode_to_vec().unwrap(),
+            &test_case.remove_proposal
+        );
+
+        let proposal = ReInitProposal::mls_decode(&mut &*test_case.re_init_proposal).unwrap();
+
+        assert_eq!(
+            &proposal.mls_encode_to_vec().unwrap(),
+            &test_case.re_init_proposal
+        );
+
+        let proposal =
+            PreSharedKeyProposal::mls_decode(&mut &*test_case.pre_shared_key_proposal).unwrap();
+
+        assert_eq!(
+            &proposal.mls_encode_to_vec().unwrap(),
+            &test_case.pre_shared_key_proposal
+        );
+
+        let proposal = ExternalInit::mls_decode(&mut &*test_case.external_init_proposal).unwrap();
+
+        assert_eq!(
+            &proposal.mls_encode_to_vec().unwrap(),
+            &test_case.external_init_proposal
+        );
+
+        let proposal =
+            ExtensionList::mls_decode(&mut &*test_case.group_context_extensions_proposal).unwrap();
+
+        assert_eq!(
+            &proposal.mls_encode_to_vec().unwrap(),
+            &test_case.group_context_extensions_proposal
+        );
+
+        let commit = Commit::mls_decode(&mut &*test_case.commit).unwrap();
+
+        assert_eq!(&commit.mls_encode_to_vec().unwrap(), &test_case.commit);
+
+        let message = MlsMessage::from_bytes(&test_case.public_message_application).unwrap();
+        let serialized = message.mls_encode_to_vec().unwrap();
+        assert_eq!(&serialized, &test_case.public_message_application);
+        let content_type = message.into_plaintext().unwrap().content.content_type();
+        assert_eq!(content_type, ContentType::Application);
+
+        let message = MlsMessage::from_bytes(&test_case.public_message_proposal).unwrap();
+        let serialized = message.mls_encode_to_vec().unwrap();
+        assert_eq!(&serialized, &test_case.public_message_proposal);
+        let content_type = message.into_plaintext().unwrap().content.content_type();
+        assert_eq!(content_type, ContentType::Proposal);
+
+        let message = MlsMessage::from_bytes(&test_case.public_message_commit).unwrap();
+        let serialized = message.mls_encode_to_vec().unwrap();
+        assert_eq!(&serialized, &test_case.public_message_commit);
+        let content_type = message.into_plaintext().unwrap().content.content_type();
+        assert_eq!(content_type, ContentType::Commit);
+
+        let message = MlsMessage::from_bytes(&test_case.private_message).unwrap();
+        let serialized = message.mls_encode_to_vec().unwrap();
+        assert_eq!(&serialized, &test_case.private_message);
+        message.into_ciphertext().unwrap();
+    }
+}
diff --git a/src/group/interop_test_vectors/tree_kem.rs b/src/group/interop_test_vectors/tree_kem.rs
new file mode 100644
index 0000000..0a04312
--- /dev/null
+++ b/src/group/interop_test_vectors/tree_kem.rs
@@ -0,0 +1,185 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// Copyright by contributors to this project.
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+
+use crate::{
+    client::test_utils::TEST_PROTOCOL_VERSION,
+    crypto::test_utils::try_test_cipher_suite_provider,
+    group::{
+        confirmation_tag::ConfirmationTag, framing::Content, message_processor::MessageProcessor,
+        message_signature::AuthenticatedContent, test_utils::GroupWithoutKeySchedule, Commit,
+        GroupContext, PathSecret, Sender,
+    },
+    identity::basic::BasicIdentityProvider,
+    tree_kem::{
+        node::{LeafIndex, NodeVec},
+        TreeKemPrivate, TreeKemPublic, UpdatePath,
+    },
+    WireFormat,
+};
+use alloc::vec;
+use alloc::vec::Vec;
+use mls_rs_codec::MlsDecode;
+use mls_rs_core::{crypto::CipherSuiteProvider, extension::ExtensionList};
+
+#[derive(serde::Serialize, serde::Deserialize, Debug, Default, Clone)]
+struct TreeKemTestCase {
+    pub cipher_suite: u16,
+
+    #[serde(with = "hex::serde")]
+    pub group_id: Vec<u8>,
+    epoch: u64,
+    #[serde(with = "hex::serde")]
+    confirmed_transcript_hash: Vec<u8>,
+    #[serde(with = "hex::serde")]
+    ratchet_tree: Vec<u8>,
+
+    leaves_private: Vec<TestLeafPrivate>,
+    update_paths: Vec<TestUpdatePath>,
+}
+
+#[derive(serde::Serialize, serde::Deserialize, Debug, Default, Clone)]
+struct TestLeafPrivate {
+    index: u32,
+    #[serde(with = "hex::serde")]
+    encryption_priv: Vec<u8>,
+    #[serde(with = "hex::serde")]
+    signature_priv: Vec<u8>,
+    path_secrets: Vec<TestPathSecretPrivate>,
+}
+
+#[derive(serde::Serialize, serde::Deserialize, Debug, Default, Clone)]
+struct TestPathSecretPrivate {
+    node: u32,
+    #[serde(with = "hex::serde")]
+    path_secret: Vec<u8>,
+}
+
+#[derive(serde::Serialize, serde::Deserialize, Debug, Default, Clone)]
+struct TestUpdatePath {
+    sender: u32,
+    #[serde(with = "hex::serde")]
+    update_path: Vec<u8>,
+    #[serde(with = "hex::serde")]
+    tree_hash_after: Vec<u8>,
+    #[serde(with = "hex::serde")]
+    commit_secret: Vec<u8>,
+}
+
+#[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+async fn tree_kem() {
+    // The test vector can be found here https://github.com/mlswg/mls-implementations/blob/main/test-vectors/treekem.json
+
+    let test_cases: Vec<TreeKemTestCase> =
+        load_test_case_json!(interop_tree_kem, Vec::<TreeKemTestCase>::new());
+
+    for test_case in test_cases {
+        let Some(cs) = try_test_cipher_suite_provider(test_case.cipher_suite) else {
+            continue;
+        };
+
+        // Import the public ratchet tree
+        let nodes = NodeVec::mls_decode(&mut &*test_case.ratchet_tree).unwrap();
+
+        let mut tree =
+            TreeKemPublic::import_node_data(nodes, &BasicIdentityProvider, &Default::default())
+                .await
+                .unwrap();
+
+        // Construct GroupContext
+        let group_context = GroupContext {
+            protocol_version: TEST_PROTOCOL_VERSION,
+            cipher_suite: cs.cipher_suite(),
+            group_id: test_case.group_id,
+            epoch: test_case.epoch,
+            tree_hash: tree.tree_hash(&cs).await.unwrap(),
+            confirmed_transcript_hash: test_case.confirmed_transcript_hash.into(),
+            extensions: ExtensionList::new(),
+        };
+
+        for leaf in test_case.leaves_private.iter() {
+            // Construct the private ratchet tree
+            let mut tree_private = TreeKemPrivate::new(LeafIndex(leaf.index));
+
+            // Set and validate HPKE keys on direct path
+            let path = tree.nodes.direct_copath(tree_private.self_index);
+
+            tree_private.secret_keys = Vec::new();
+
+            for dp in path {
+                let dp = dp.path;
+
+                let secret = leaf
+                    .path_secrets
+                    .iter()
+                    .find_map(|s| (s.node == dp).then_some(s.path_secret.clone()));
+
+                let private_key = if let Some(secret) = secret {
+                    let (secret_key, public_key) = PathSecret::from(secret)
+                        .to_hpke_key_pair(&cs)
+                        .await
+                        .unwrap();
+
+                    let tree_public = &tree.nodes.borrow_as_parent(dp).unwrap().public_key;
+                    assert_eq!(&public_key, tree_public);
+
+                    Some(secret_key)
+                } else {
+                    None
+                };
+
+                tree_private.secret_keys.push(private_key);
+            }
+
+            // Set HPKE key for leaf
+            tree_private
+                .secret_keys
+                .insert(0, Some(leaf.encryption_priv.clone().into()));
+
+            let paths = test_case
+                .update_paths
+                .iter()
+                .filter(|path| path.sender != leaf.index);
+
+            for update_path in paths {
+                let mut group = GroupWithoutKeySchedule::new(cs.cipher_suite()).await;
+                group.state.context = group_context.clone();
+                group.state.public_tree = tree.clone();
+                group.private_tree = tree_private.clone();
+
+                let path = UpdatePath::mls_decode(&mut &*update_path.update_path).unwrap();
+
+                let commit = Commit {
+                    proposals: vec![],
+                    path: Some(path),
+                };
+
+                let mut auth_content = AuthenticatedContent::new(
+                    &group_context,
+                    Sender::Member(update_path.sender),
+                    Content::Commit(alloc::boxed::Box::new(commit)),
+                    vec![],
+                    WireFormat::PublicMessage,
+                );
+
+                auth_content.auth.confirmation_tag = Some(ConfirmationTag::empty(&cs).await);
+
+                // Hack not to increment epoch
+                group.state.context.epoch -= 1;
+
+                group.process_commit(auth_content, None).await.unwrap();
+
+                // Check that we got the expected commit secret and correctly merged the update path.
+                // This implies that we computed the path secrets correctly.
+                let commit_secret = group.secrets.unwrap().1;
+
+                assert_eq!(&*commit_secret, &update_path.commit_secret);
+
+                let new_tree = &mut group.provisional_public_state.unwrap().public_tree;
+                let new_tree_hash = new_tree.tree_hash(&cs).await.unwrap();
+
+                assert_eq!(&new_tree_hash, &update_path.tree_hash_after);
+            }
+        }
+    }
+}
diff --git a/src/group/interop_test_vectors/tree_modifications.rs b/src/group/interop_test_vectors/tree_modifications.rs
new file mode 100644
index 0000000..a172e0c
--- /dev/null
+++ b/src/group/interop_test_vectors/tree_modifications.rs
@@ -0,0 +1,177 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// Copyright by contributors to this project.
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+
+use alloc::boxed::Box;
+use alloc::vec;
+use alloc::vec::Vec;
+use mls_rs_codec::{MlsDecode, MlsEncode};
+use mls_rs_core::crypto::CipherSuite;
+
+use crate::{
+    client::test_utils::{TEST_CIPHER_SUITE, TEST_PROTOCOL_VERSION},
+    crypto::test_utils::{test_cipher_suite_provider, try_test_cipher_suite_provider},
+    group::{
+        proposal::{AddProposal, Proposal, ProposalOrRef, RemoveProposal, UpdateProposal},
+        proposal_cache::test_utils::CommitReceiver,
+        proposal_ref::ProposalRef,
+        test_utils::TEST_GROUP,
+        LeafIndex, Sender, TreeKemPublic,
+    },
+    identity::basic::BasicIdentityProvider,
+    key_package::test_utils::test_key_package,
+    tree_kem::{
+        leaf_node::test_utils::default_properties, node::NodeVec, test_utils::TreeWithSigners,
+    },
+};
+
+#[derive(serde::Serialize, serde::Deserialize, Debug, Default, Clone)]
+struct TreeModsTestCase {
+    #[serde(with = "hex::serde")]
+    pub tree_before: Vec<u8>,
+    #[serde(with = "hex::serde")]
+    pub proposal: Vec<u8>,
+    pub proposal_sender: u32,
+    #[serde(with = "hex::serde")]
+    pub tree_after: Vec<u8>,
+}
+
+impl TreeModsTestCase {
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    #[cfg_attr(coverage_nightly, coverage(off))]
+    async fn new(tree_before: TreeKemPublic, proposal: Proposal, proposal_sender: u32) -> Self {
+        let tree_after = apply_proposal(proposal.clone(), proposal_sender, &tree_before).await;
+
+        Self {
+            tree_before: tree_before.nodes.mls_encode_to_vec().unwrap(),
+            proposal: proposal.mls_encode_to_vec().unwrap(),
+            tree_after: tree_after.nodes.mls_encode_to_vec().unwrap(),
+            proposal_sender,
+        }
+    }
+}
+
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+#[cfg_attr(coverage_nightly, coverage(off))]
+async fn generate_tree_mods_tests() -> Vec<TreeModsTestCase> {
+    let mut test_vector = vec![];
+    let cs = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+
+    // Update
+    let tree_before = TreeWithSigners::make_full_tree(8, &cs).await;
+    let update = generate_update(6, &tree_before).await;
+    test_vector.push(TreeModsTestCase::new(tree_before.tree, update, 6).await);
+
+    // Add in the middle
+    let mut tree_before = TreeWithSigners::make_full_tree(6, &cs).await;
+    tree_before.remove_member(3);
+    test_vector.push(TreeModsTestCase::new(tree_before.tree, generate_add().await, 2).await);
+
+    // Add at the end
+    let tree_before = TreeWithSigners::make_full_tree(6, &cs).await;
+    test_vector.push(TreeModsTestCase::new(tree_before.tree, generate_add().await, 2).await);
+
+    // Add at the end, tree grows
+    let tree_before = TreeWithSigners::make_full_tree(8, &cs).await;
+    test_vector.push(TreeModsTestCase::new(tree_before.tree, generate_add().await, 2).await);
+
+    // Remove in the middle
+    let tree_before = TreeWithSigners::make_full_tree(8, &cs).await;
+    test_vector.push(TreeModsTestCase::new(tree_before.tree, generate_remove(2), 2).await);
+
+    // Remove at the end
+    let tree_before = TreeWithSigners::make_full_tree(8, &cs).await;
+    test_vector.push(TreeModsTestCase::new(tree_before.tree, generate_remove(7), 2).await);
+
+    // Remove at the end, tree shrinks
+    let tree_before = TreeWithSigners::make_full_tree(9, &cs).await;
+    test_vector.push(TreeModsTestCase::new(tree_before.tree, generate_remove(8), 2).await);
+
+    test_vector
+}
+
+#[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+async fn tree_modifications_interop() {
+    // The test vector can be found here https://github.com/mlswg/mls-implementations/blob/main/test-vectors/tree-operations.json
+
+    // All test vectors use cipher suite 1
+    if try_test_cipher_suite_provider(*CipherSuite::CURVE25519_AES128).is_none() {
+        return;
+    }
+
+    #[cfg(not(mls_build_async))]
+    let test_cases: Vec<TreeModsTestCase> =
+        load_test_case_json!(tree_modifications_interop, generate_tree_mods_tests());
+
+    #[cfg(mls_build_async)]
+    let test_cases: Vec<TreeModsTestCase> =
+        load_test_case_json!(tree_modifications_interop, generate_tree_mods_tests().await);
+
+    for test_case in test_cases.into_iter() {
+        let nodes = NodeVec::mls_decode(&mut &*test_case.tree_before).unwrap();
+
+        let tree_before =
+            TreeKemPublic::import_node_data(nodes, &BasicIdentityProvider, &Default::default())
+                .await
+                .unwrap();
+
+        let proposal = Proposal::mls_decode(&mut &*test_case.proposal).unwrap();
+
+        let tree_after = apply_proposal(proposal, test_case.proposal_sender, &tree_before).await;
+
+        let tree_after = tree_after.nodes.mls_encode_to_vec().unwrap();
+
+        assert_eq!(tree_after, test_case.tree_after);
+    }
+}
+
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+async fn apply_proposal(
+    proposal: Proposal,
+    sender: u32,
+    tree_before: &TreeKemPublic,
+) -> TreeKemPublic {
+    let cs = test_cipher_suite_provider(CipherSuite::CURVE25519_AES128);
+    let p_ref = ProposalRef::new_fake(b"fake ref".to_vec());
+
+    CommitReceiver::new(tree_before, Sender::Member(0), LeafIndex(1), cs)
+        .cache(p_ref.clone(), proposal, Sender::Member(sender))
+        .receive(vec![ProposalOrRef::Reference(p_ref)])
+        .await
+        .unwrap()
+        .public_tree
+}
+
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+#[cfg_attr(coverage_nightly, coverage(off))]
+async fn generate_add() -> Proposal {
+    let key_package = test_key_package(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, "Roger").await;
+    Proposal::Add(Box::new(AddProposal { key_package }))
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+fn generate_remove(i: u32) -> Proposal {
+    let to_remove = LeafIndex(i);
+    Proposal::Remove(RemoveProposal { to_remove })
+}
+
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+#[cfg_attr(coverage_nightly, coverage(off))]
+async fn generate_update(i: u32, tree: &TreeWithSigners) -> Proposal {
+    let signer = tree.signers[i as usize].as_ref().unwrap();
+    let mut leaf_node = tree.tree.get_leaf_node(LeafIndex(i)).unwrap().clone();
+
+    leaf_node
+        .update(
+            &test_cipher_suite_provider(TEST_CIPHER_SUITE),
+            TEST_GROUP,
+            i,
+            default_properties(),
+            None,
+            signer,
+        )
+        .await
+        .unwrap();
+
+    Proposal::Update(UpdateProposal { leaf_node })
+}
diff --git a/src/group/key_schedule.rs b/src/group/key_schedule.rs
new file mode 100644
index 0000000..77c1d65
--- /dev/null
+++ b/src/group/key_schedule.rs
@@ -0,0 +1,988 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// Copyright by contributors to this project.
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+
+use crate::client::MlsError;
+use crate::extension::ExternalPubExt;
+use crate::group::{GroupContext, MembershipTag};
+use crate::psk::secret::PskSecret;
+#[cfg(feature = "psk")]
+use crate::psk::PreSharedKey;
+use crate::tree_kem::path_secret::PathSecret;
+use crate::CipherSuiteProvider;
+
+#[cfg(any(feature = "secret_tree_access", feature = "private_message"))]
+use crate::group::SecretTree;
+
+use alloc::vec;
+use alloc::vec::Vec;
+use core::fmt::{self, Debug};
+use mls_rs_codec::{MlsDecode, MlsEncode, MlsSize};
+use mls_rs_core::error::IntoAnyError;
+use zeroize::Zeroizing;
+
+use crate::crypto::{HpkeContextR, HpkeContextS, HpkePublicKey, HpkeSecretKey};
+
+use super::epoch::{EpochSecrets, SenderDataSecret};
+use super::message_signature::AuthenticatedContent;
+
+#[derive(Clone, PartialEq, Eq, Default, MlsEncode, MlsDecode, MlsSize)]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+pub struct KeySchedule {
+    #[mls_codec(with = "mls_rs_codec::byte_vec")]
+    #[cfg_attr(feature = "serde", serde(with = "mls_rs_core::zeroizing_serde"))]
+    exporter_secret: Zeroizing<Vec<u8>>,
+    #[mls_codec(with = "mls_rs_codec::byte_vec")]
+    #[cfg_attr(feature = "serde", serde(with = "mls_rs_core::zeroizing_serde"))]
+    pub authentication_secret: Zeroizing<Vec<u8>>,
+    #[mls_codec(with = "mls_rs_codec::byte_vec")]
+    #[cfg_attr(feature = "serde", serde(with = "mls_rs_core::zeroizing_serde"))]
+    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>>,
+    init_secret: InitSecret,
+}
+
+impl Debug for KeySchedule {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.debug_struct("KeySchedule")
+            .field(
+                "exporter_secret",
+                &mls_rs_core::debug::pretty_bytes(&self.exporter_secret),
+            )
+            .field(
+                "authentication_secret",
+                &mls_rs_core::debug::pretty_bytes(&self.authentication_secret),
+            )
+            .field(
+                "external_secret",
+                &mls_rs_core::debug::pretty_bytes(&self.external_secret),
+            )
+            .field(
+                "membership_key",
+                &mls_rs_core::debug::pretty_bytes(&self.membership_key),
+            )
+            .field("init_secret", &self.init_secret)
+            .finish()
+    }
+}
+
+pub(crate) struct KeyScheduleDerivationResult {
+    pub(crate) key_schedule: KeySchedule,
+    pub(crate) confirmation_key: Zeroizing<Vec<u8>>,
+    pub(crate) joiner_secret: JoinerSecret,
+    pub(crate) epoch_secrets: EpochSecrets,
+}
+
+impl KeySchedule {
+    pub fn new(init_secret: InitSecret) -> Self {
+        KeySchedule {
+            init_secret,
+            ..Default::default()
+        }
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn derive_for_external<P: CipherSuiteProvider>(
+        &self,
+        kem_output: &[u8],
+        cipher_suite: &P,
+    ) -> Result<KeySchedule, MlsError> {
+        let (secret, public) = self.get_external_key_pair(cipher_suite).await?;
+
+        let init_secret =
+            InitSecret::decode_for_external(cipher_suite, kem_output, &secret, &public).await?;
+
+        Ok(KeySchedule::new(init_secret))
+    }
+
+    /// Returns the derived epoch as well as the joiner secret required for building welcome
+    /// messages
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(crate) async fn from_key_schedule<P: CipherSuiteProvider>(
+        last_key_schedule: &KeySchedule,
+        commit_secret: &PathSecret,
+        context: &GroupContext,
+        #[cfg(any(feature = "secret_tree_access", feature = "private_message"))]
+        secret_tree_size: u32,
+        psk_secret: &PskSecret,
+        cipher_suite_provider: &P,
+    ) -> Result<KeyScheduleDerivationResult, MlsError> {
+        let joiner_seed = cipher_suite_provider
+            .kdf_extract(&last_key_schedule.init_secret.0, commit_secret)
+            .await
+            .map_err(|e| MlsError::CryptoProviderError(e.into_any_error()))?;
+
+        let joiner_secret = kdf_expand_with_label(
+            cipher_suite_provider,
+            &joiner_seed,
+            b"joiner",
+            &context.mls_encode_to_vec()?,
+            None,
+        )
+        .await?
+        .into();
+
+        let key_schedule_result = Self::from_joiner(
+            cipher_suite_provider,
+            &joiner_secret,
+            context,
+            #[cfg(any(feature = "secret_tree_access", feature = "private_message"))]
+            secret_tree_size,
+            psk_secret,
+        )
+        .await?;
+
+        Ok(KeyScheduleDerivationResult {
+            key_schedule: key_schedule_result.key_schedule,
+            confirmation_key: key_schedule_result.confirmation_key,
+            joiner_secret,
+            epoch_secrets: key_schedule_result.epoch_secrets,
+        })
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(crate) async fn from_joiner<P: CipherSuiteProvider>(
+        cipher_suite_provider: &P,
+        joiner_secret: &JoinerSecret,
+        context: &GroupContext,
+        #[cfg(any(feature = "secret_tree_access", feature = "private_message"))]
+        secret_tree_size: u32,
+        psk_secret: &PskSecret,
+    ) -> Result<KeyScheduleDerivationResult, MlsError> {
+        let epoch_seed =
+            get_pre_epoch_secret(cipher_suite_provider, psk_secret, joiner_secret).await?;
+        let context = context.mls_encode_to_vec()?;
+
+        let epoch_secret =
+            kdf_expand_with_label(cipher_suite_provider, &epoch_seed, b"epoch", &context, None)
+                .await?;
+
+        Self::from_epoch_secret(
+            cipher_suite_provider,
+            &epoch_secret,
+            #[cfg(any(feature = "secret_tree_access", feature = "private_message"))]
+            secret_tree_size,
+        )
+        .await
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(crate) async fn from_random_epoch_secret<P: CipherSuiteProvider>(
+        cipher_suite_provider: &P,
+        #[cfg(any(feature = "secret_tree_access", feature = "private_message"))]
+        secret_tree_size: u32,
+    ) -> Result<KeyScheduleDerivationResult, MlsError> {
+        let epoch_secret = cipher_suite_provider
+            .random_bytes_vec(cipher_suite_provider.kdf_extract_size())
+            .map(Zeroizing::new)
+            .map_err(|e| MlsError::CryptoProviderError(e.into_any_error()))?;
+
+        Self::from_epoch_secret(
+            cipher_suite_provider,
+            &epoch_secret,
+            #[cfg(any(feature = "secret_tree_access", feature = "private_message"))]
+            secret_tree_size,
+        )
+        .await
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn from_epoch_secret<P: CipherSuiteProvider>(
+        cipher_suite_provider: &P,
+        epoch_secret: &[u8],
+        #[cfg(any(feature = "secret_tree_access", feature = "private_message"))]
+        secret_tree_size: u32,
+    ) -> Result<KeyScheduleDerivationResult, MlsError> {
+        let secrets_producer = SecretsProducer::new(cipher_suite_provider, epoch_secret);
+
+        let epoch_secrets = EpochSecrets {
+            #[cfg(feature = "psk")]
+            resumption_secret: PreSharedKey::from(secrets_producer.derive(b"resumption").await?),
+            sender_data_secret: SenderDataSecret::from(
+                secrets_producer.derive(b"sender data").await?,
+            ),
+            #[cfg(any(feature = "secret_tree_access", feature = "private_message"))]
+            secret_tree: SecretTree::new(
+                secret_tree_size,
+                secrets_producer.derive(b"encryption").await?,
+            ),
+        };
+
+        let key_schedule = Self {
+            exporter_secret: secrets_producer.derive(b"exporter").await?,
+            authentication_secret: secrets_producer.derive(b"authentication").await?,
+            external_secret: secrets_producer.derive(b"external").await?,
+            membership_key: secrets_producer.derive(b"membership").await?,
+            init_secret: InitSecret(secrets_producer.derive(b"init").await?),
+        };
+
+        Ok(KeyScheduleDerivationResult {
+            key_schedule,
+            confirmation_key: secrets_producer.derive(b"confirm").await?,
+            joiner_secret: Zeroizing::new(vec![]).into(),
+            epoch_secrets,
+        })
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn export_secret<P: CipherSuiteProvider>(
+        &self,
+        label: &[u8],
+        context: &[u8],
+        len: usize,
+        cipher_suite: &P,
+    ) -> Result<Zeroizing<Vec<u8>>, MlsError> {
+        let secret = kdf_derive_secret(cipher_suite, &self.exporter_secret, label).await?;
+
+        let context_hash = cipher_suite
+            .hash(context)
+            .await
+            .map_err(|e| MlsError::CryptoProviderError(e.into_any_error()))?;
+
+        kdf_expand_with_label(cipher_suite, &secret, b"exported", &context_hash, Some(len)).await
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn get_membership_tag<P: CipherSuiteProvider>(
+        &self,
+        content: &AuthenticatedContent,
+        context: &GroupContext,
+        cipher_suite_provider: &P,
+    ) -> Result<MembershipTag, MlsError> {
+        MembershipTag::create(
+            content,
+            context,
+            &self.membership_key,
+            cipher_suite_provider,
+        )
+        .await
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn get_external_key_pair<P: CipherSuiteProvider>(
+        &self,
+        cipher_suite: &P,
+    ) -> Result<(HpkeSecretKey, HpkePublicKey), MlsError> {
+        cipher_suite
+            .kem_derive(&self.external_secret)
+            .await
+            .map_err(|e| MlsError::CryptoProviderError(e.into_any_error()))
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn get_external_key_pair_ext<P: CipherSuiteProvider>(
+        &self,
+        cipher_suite: &P,
+    ) -> Result<ExternalPubExt, MlsError> {
+        let (_external_secret, external_pub) = self.get_external_key_pair(cipher_suite).await?;
+
+        Ok(ExternalPubExt { external_pub })
+    }
+}
+
+#[derive(MlsEncode, MlsSize)]
+struct Label<'a> {
+    length: u16,
+    #[mls_codec(with = "mls_rs_codec::byte_vec")]
+    label: Vec<u8>,
+    #[mls_codec(with = "mls_rs_codec::byte_vec")]
+    context: &'a [u8],
+}
+
+impl<'a> Label<'a> {
+    fn new(length: u16, label: &'a [u8], context: &'a [u8]) -> Self {
+        Self {
+            length,
+            label: [b"MLS 1.0 ", label].concat(),
+            context,
+        }
+    }
+}
+
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+pub(crate) async fn kdf_expand_with_label<P: CipherSuiteProvider>(
+    cipher_suite_provider: &P,
+    secret: &[u8],
+    label: &[u8],
+    context: &[u8],
+    len: Option<usize>,
+) -> Result<Zeroizing<Vec<u8>>, MlsError> {
+    let extract_size = cipher_suite_provider.kdf_extract_size();
+    let len = len.unwrap_or(extract_size);
+    let label = Label::new(len as u16, label, context);
+
+    cipher_suite_provider
+        .kdf_expand(secret, &label.mls_encode_to_vec()?, len)
+        .await
+        .map_err(|e| MlsError::CryptoProviderError(e.into_any_error()))
+}
+
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+pub(crate) async fn kdf_derive_secret<P: CipherSuiteProvider>(
+    cipher_suite_provider: &P,
+    secret: &[u8],
+    label: &[u8],
+) -> Result<Zeroizing<Vec<u8>>, MlsError> {
+    kdf_expand_with_label(cipher_suite_provider, secret, label, &[], None).await
+}
+
+#[derive(Clone, PartialEq, MlsSize, MlsEncode, MlsDecode)]
+pub(crate) struct JoinerSecret(#[mls_codec(with = "mls_rs_codec::byte_vec")] Zeroizing<Vec<u8>>);
+
+impl Debug for JoinerSecret {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        mls_rs_core::debug::pretty_bytes(&self.0)
+            .named("JoinerSecret")
+            .fmt(f)
+    }
+}
+
+impl From<Zeroizing<Vec<u8>>> for JoinerSecret {
+    fn from(bytes: Zeroizing<Vec<u8>>) -> Self {
+        Self(bytes)
+    }
+}
+
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+pub(crate) async fn get_pre_epoch_secret<P: CipherSuiteProvider>(
+    cipher_suite_provider: &P,
+    psk_secret: &PskSecret,
+    joiner_secret: &JoinerSecret,
+) -> Result<Zeroizing<Vec<u8>>, MlsError> {
+    cipher_suite_provider
+        .kdf_extract(&joiner_secret.0, psk_secret)
+        .await
+        .map_err(|e| MlsError::CryptoProviderError(e.into_any_error()))
+}
+
+struct SecretsProducer<'a, P: CipherSuiteProvider> {
+    cipher_suite_provider: &'a P,
+    epoch_secret: &'a [u8],
+}
+
+impl<'a, P: CipherSuiteProvider> SecretsProducer<'a, P> {
+    fn new(cipher_suite_provider: &'a P, epoch_secret: &'a [u8]) -> Self {
+        Self {
+            cipher_suite_provider,
+            epoch_secret,
+        }
+    }
+
+    // TODO document somewhere in the crypto provider that the RFC defines the length of all secrets as
+    // KDF extract size but then inputs secrets as MAC keys etc, therefore, we require that these
+    // lengths match in the crypto provider
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn derive(&self, label: &[u8]) -> Result<Zeroizing<Vec<u8>>, MlsError> {
+        kdf_derive_secret(self.cipher_suite_provider, self.epoch_secret, label).await
+    }
+}
+
+const EXPORTER_CONTEXT: &[u8] = b"MLS 1.0 external init secret";
+
+#[derive(Clone, Eq, PartialEq, MlsEncode, MlsDecode, MlsSize, Default)]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+pub struct InitSecret(
+    #[mls_codec(with = "mls_rs_codec::byte_vec")]
+    #[cfg_attr(feature = "serde", serde(with = "mls_rs_core::zeroizing_serde"))]
+    Zeroizing<Vec<u8>>,
+);
+
+impl Debug for InitSecret {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        mls_rs_core::debug::pretty_bytes(&self.0)
+            .named("InitSecret")
+            .fmt(f)
+    }
+}
+
+impl InitSecret {
+    /// Returns init secret and KEM output to be used when creating an external commit.
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn encode_for_external<P: CipherSuiteProvider>(
+        cipher_suite: &P,
+        external_pub: &HpkePublicKey,
+    ) -> Result<(Self, Vec<u8>), MlsError> {
+        let (kem_output, context) = cipher_suite
+            .hpke_setup_s(external_pub, &[])
+            .await
+            .map_err(|e| MlsError::CryptoProviderError(e.into_any_error()))?;
+
+        let init_secret = context
+            .export(EXPORTER_CONTEXT, cipher_suite.kdf_extract_size())
+            .await
+            .map_err(|e| MlsError::CryptoProviderError(e.into_any_error()))?;
+
+        Ok((InitSecret(Zeroizing::new(init_secret)), kem_output))
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn decode_for_external<P: CipherSuiteProvider>(
+        cipher_suite: &P,
+        kem_output: &[u8],
+        external_secret: &HpkeSecretKey,
+        external_pub: &HpkePublicKey,
+    ) -> Result<Self, MlsError> {
+        let context = cipher_suite
+            .hpke_setup_r(kem_output, external_secret, external_pub, &[])
+            .await
+            .map_err(|e| MlsError::CryptoProviderError(e.into_any_error()))?;
+
+        context
+            .export(EXPORTER_CONTEXT, cipher_suite.kdf_extract_size())
+            .await
+            .map(Zeroizing::new)
+            .map(InitSecret)
+            .map_err(|e| MlsError::CryptoProviderError(e.into_any_error()))
+    }
+}
+
+pub(crate) struct WelcomeSecret<'a, P: CipherSuiteProvider> {
+    cipher_suite: &'a P,
+    key: Zeroizing<Vec<u8>>,
+    nonce: Zeroizing<Vec<u8>>,
+}
+
+impl<'a, P: CipherSuiteProvider> WelcomeSecret<'a, P> {
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(crate) async fn from_joiner_secret(
+        cipher_suite: &'a P,
+        joiner_secret: &JoinerSecret,
+        psk_secret: &PskSecret,
+    ) -> Result<WelcomeSecret<'a, P>, MlsError> {
+        let welcome_secret = get_welcome_secret(cipher_suite, joiner_secret, psk_secret).await?;
+
+        let key_len = cipher_suite.aead_key_size();
+        let key = kdf_expand_with_label(cipher_suite, &welcome_secret, b"key", &[], Some(key_len))
+            .await?;
+
+        let nonce_len = cipher_suite.aead_nonce_size();
+
+        let nonce = kdf_expand_with_label(
+            cipher_suite,
+            &welcome_secret,
+            b"nonce",
+            &[],
+            Some(nonce_len),
+        )
+        .await?;
+
+        Ok(Self {
+            cipher_suite,
+            key,
+            nonce,
+        })
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(crate) async fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, MlsError> {
+        self.cipher_suite
+            .aead_seal(&self.key, plaintext, None, &self.nonce)
+            .await
+            .map_err(|e| MlsError::CryptoProviderError(e.into_any_error()))
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(crate) async fn decrypt(&self, ciphertext: &[u8]) -> Result<Zeroizing<Vec<u8>>, MlsError> {
+        self.cipher_suite
+            .aead_open(&self.key, ciphertext, None, &self.nonce)
+            .await
+            .map_err(|e| MlsError::CryptoProviderError(e.into_any_error()))
+    }
+}
+
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+async fn get_welcome_secret<P: CipherSuiteProvider>(
+    cipher_suite: &P,
+    joiner_secret: &JoinerSecret,
+    psk_secret: &PskSecret,
+) -> Result<Zeroizing<Vec<u8>>, MlsError> {
+    let epoch_seed = get_pre_epoch_secret(cipher_suite, psk_secret, joiner_secret).await?;
+    kdf_derive_secret(cipher_suite, &epoch_seed, b"welcome").await
+}
+
+#[cfg(test)]
+pub(crate) mod test_utils {
+    use alloc::vec;
+    use alloc::vec::Vec;
+    use mls_rs_core::crypto::CipherSuiteProvider;
+    use zeroize::Zeroizing;
+
+    use crate::{cipher_suite::CipherSuite, crypto::test_utils::test_cipher_suite_provider};
+
+    use super::{InitSecret, JoinerSecret, KeySchedule};
+
+    #[cfg(all(feature = "rfc_compliant", not(mls_build_async)))]
+    use mls_rs_core::error::IntoAnyError;
+
+    #[cfg(all(feature = "rfc_compliant", not(mls_build_async)))]
+    use super::MlsError;
+
+    impl From<JoinerSecret> for Vec<u8> {
+        fn from(mut value: JoinerSecret) -> Self {
+            core::mem::take(&mut value.0)
+        }
+    }
+
+    pub(crate) fn get_test_key_schedule(cipher_suite: CipherSuite) -> KeySchedule {
+        let key_size = test_cipher_suite_provider(cipher_suite).kdf_extract_size();
+        let fake_secret = Zeroizing::new(vec![1u8; key_size]);
+
+        KeySchedule {
+            exporter_secret: fake_secret.clone(),
+            authentication_secret: fake_secret.clone(),
+            external_secret: fake_secret.clone(),
+            membership_key: fake_secret,
+            init_secret: InitSecret::new(vec![0u8; key_size]),
+        }
+    }
+
+    impl InitSecret {
+        pub fn new(init_secret: Vec<u8>) -> Self {
+            InitSecret(Zeroizing::new(init_secret))
+        }
+
+        #[cfg(all(feature = "rfc_compliant", test, not(mls_build_async)))]
+        #[cfg_attr(coverage_nightly, coverage(off))]
+        pub fn random<P: CipherSuiteProvider>(cipher_suite: &P) -> Result<Self, MlsError> {
+            cipher_suite
+                .random_bytes_vec(cipher_suite.kdf_extract_size())
+                .map(Zeroizing::new)
+                .map(InitSecret)
+                .map_err(|e| MlsError::CryptoProviderError(e.into_any_error()))
+        }
+    }
+
+    #[cfg(feature = "rfc_compliant")]
+    impl KeySchedule {
+        pub fn set_membership_key(&mut self, key: Vec<u8>) {
+            self.membership_key = Zeroizing::new(key)
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use crate::client::test_utils::TEST_PROTOCOL_VERSION;
+    use crate::crypto::test_utils::try_test_cipher_suite_provider;
+    use crate::group::key_schedule::{
+        get_welcome_secret, kdf_derive_secret, kdf_expand_with_label,
+    };
+    use crate::group::GroupContext;
+    use alloc::string::String;
+    use alloc::vec::Vec;
+    use mls_rs_codec::MlsEncode;
+    use mls_rs_core::crypto::CipherSuiteProvider;
+    use mls_rs_core::extension::ExtensionList;
+
+    #[cfg(all(not(mls_build_async), feature = "rfc_compliant"))]
+    use crate::{
+        crypto::test_utils::{test_cipher_suite_provider, TestCryptoProvider},
+        group::{
+            key_schedule::KeyScheduleDerivationResult, test_utils::random_bytes, InitSecret,
+            PskSecret,
+        },
+    };
+
+    #[cfg(all(not(mls_build_async), feature = "rfc_compliant"))]
+    use alloc::{string::ToString, vec};
+
+    #[cfg(target_arch = "wasm32")]
+    use wasm_bindgen_test::wasm_bindgen_test as test;
+    use zeroize::Zeroizing;
+
+    use super::test_utils::get_test_key_schedule;
+    use super::KeySchedule;
+
+    #[derive(serde::Deserialize, serde::Serialize)]
+    struct TestCase {
+        cipher_suite: u16,
+        #[serde(with = "hex::serde")]
+        group_id: Vec<u8>,
+        #[serde(with = "hex::serde")]
+        initial_init_secret: Vec<u8>,
+        epochs: Vec<KeyScheduleEpoch>,
+    }
+
+    #[derive(serde::Deserialize, serde::Serialize)]
+    struct KeyScheduleEpoch {
+        #[serde(with = "hex::serde")]
+        commit_secret: Vec<u8>,
+        #[serde(with = "hex::serde")]
+        psk_secret: Vec<u8>,
+        #[serde(with = "hex::serde")]
+        confirmed_transcript_hash: Vec<u8>,
+        #[serde(with = "hex::serde")]
+        tree_hash: Vec<u8>,
+
+        #[serde(with = "hex::serde")]
+        group_context: Vec<u8>,
+
+        #[serde(with = "hex::serde")]
+        joiner_secret: Vec<u8>,
+        #[serde(with = "hex::serde")]
+        welcome_secret: Vec<u8>,
+        #[serde(with = "hex::serde")]
+        init_secret: Vec<u8>,
+
+        #[serde(with = "hex::serde")]
+        sender_data_secret: Vec<u8>,
+        #[cfg(any(feature = "secret_tree_access", feature = "private_message"))]
+        #[serde(with = "hex::serde")]
+        encryption_secret: Vec<u8>,
+        #[serde(with = "hex::serde")]
+        exporter_secret: Vec<u8>,
+        #[serde(with = "hex::serde")]
+        epoch_authenticator: Vec<u8>,
+        #[serde(with = "hex::serde")]
+        external_secret: Vec<u8>,
+        #[serde(with = "hex::serde")]
+        confirmation_key: Vec<u8>,
+        #[serde(with = "hex::serde")]
+        membership_key: Vec<u8>,
+        #[cfg(feature = "psk")]
+        #[serde(with = "hex::serde")]
+        resumption_psk: Vec<u8>,
+
+        #[serde(with = "hex::serde")]
+        external_pub: Vec<u8>,
+
+        exporter: KeyScheduleExporter,
+    }
+
+    #[derive(serde::Deserialize, serde::Serialize)]
+    struct KeyScheduleExporter {
+        label: String,
+        #[serde(with = "hex::serde")]
+        context: Vec<u8>,
+        length: usize,
+        #[serde(with = "hex::serde")]
+        secret: Vec<u8>,
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_key_schedule() {
+        let test_cases: Vec<TestCase> =
+            load_test_case_json!(key_schedule_test_vector, generate_test_vector());
+
+        for test_case in test_cases {
+            let Some(cs_provider) = try_test_cipher_suite_provider(test_case.cipher_suite) else {
+                continue;
+            };
+
+            let mut key_schedule = get_test_key_schedule(cs_provider.cipher_suite());
+            key_schedule.init_secret.0 = Zeroizing::new(test_case.initial_init_secret);
+
+            for (i, epoch) in test_case.epochs.into_iter().enumerate() {
+                let context = GroupContext {
+                    protocol_version: TEST_PROTOCOL_VERSION,
+                    cipher_suite: cs_provider.cipher_suite(),
+                    group_id: test_case.group_id.clone(),
+                    epoch: i as u64,
+                    tree_hash: epoch.tree_hash,
+                    confirmed_transcript_hash: epoch.confirmed_transcript_hash.into(),
+                    extensions: ExtensionList::new(),
+                };
+
+                assert_eq!(context.mls_encode_to_vec().unwrap(), epoch.group_context);
+
+                let psk = epoch.psk_secret.into();
+                let commit = epoch.commit_secret.into();
+
+                let key_schedule_res = KeySchedule::from_key_schedule(
+                    &key_schedule,
+                    &commit,
+                    &context,
+                    #[cfg(any(feature = "secret_tree_access", feature = "private_message"))]
+                    32,
+                    &psk,
+                    &cs_provider,
+                )
+                .await
+                .unwrap();
+
+                key_schedule = key_schedule_res.key_schedule;
+
+                let welcome =
+                    get_welcome_secret(&cs_provider, &key_schedule_res.joiner_secret, &psk)
+                        .await
+                        .unwrap();
+
+                assert_eq!(*welcome, epoch.welcome_secret);
+
+                let expected: Vec<u8> = key_schedule_res.joiner_secret.into();
+                assert_eq!(epoch.joiner_secret, expected);
+
+                assert_eq!(&key_schedule.init_secret.0.to_vec(), &epoch.init_secret);
+
+                assert_eq!(
+                    epoch.sender_data_secret,
+                    *key_schedule_res.epoch_secrets.sender_data_secret.to_vec()
+                );
+
+                #[cfg(any(feature = "secret_tree_access", feature = "private_message"))]
+                assert_eq!(
+                    epoch.encryption_secret,
+                    *key_schedule_res.epoch_secrets.secret_tree.get_root_secret()
+                );
+
+                assert_eq!(epoch.exporter_secret, key_schedule.exporter_secret.to_vec());
+
+                assert_eq!(
+                    epoch.epoch_authenticator,
+                    key_schedule.authentication_secret.to_vec()
+                );
+
+                assert_eq!(epoch.external_secret, key_schedule.external_secret.to_vec());
+
+                assert_eq!(
+                    epoch.confirmation_key,
+                    key_schedule_res.confirmation_key.to_vec()
+                );
+
+                assert_eq!(epoch.membership_key, key_schedule.membership_key.to_vec());
+
+                #[cfg(feature = "psk")]
+                {
+                    let expected: Vec<u8> =
+                        key_schedule_res.epoch_secrets.resumption_secret.to_vec();
+
+                    assert_eq!(epoch.resumption_psk, expected);
+                }
+
+                let (_external_sec, external_pub) = key_schedule
+                    .get_external_key_pair(&cs_provider)
+                    .await
+                    .unwrap();
+
+                assert_eq!(epoch.external_pub, *external_pub);
+
+                let exp = epoch.exporter;
+
+                let exported = key_schedule
+                    .export_secret(exp.label.as_bytes(), &exp.context, exp.length, &cs_provider)
+                    .await
+                    .unwrap();
+
+                assert_eq!(exported.to_vec(), exp.secret);
+            }
+        }
+    }
+
+    #[cfg(all(not(mls_build_async), feature = "rfc_compliant"))]
+    #[cfg_attr(coverage_nightly, coverage(off))]
+    fn generate_test_vector() -> Vec<TestCase> {
+        let mut test_cases = vec![];
+
+        for cipher_suite in TestCryptoProvider::all_supported_cipher_suites() {
+            let cs_provider = test_cipher_suite_provider(cipher_suite);
+            let key_size = cs_provider.kdf_extract_size();
+
+            let mut group_context = GroupContext {
+                protocol_version: TEST_PROTOCOL_VERSION,
+                cipher_suite: cs_provider.cipher_suite(),
+                group_id: b"my group 5".to_vec(),
+                epoch: 0,
+                tree_hash: random_bytes(key_size),
+                confirmed_transcript_hash: random_bytes(key_size).into(),
+                extensions: Default::default(),
+            };
+
+            let initial_init_secret = InitSecret::random(&cs_provider).unwrap();
+            let mut key_schedule = get_test_key_schedule(cs_provider.cipher_suite());
+            key_schedule.init_secret = initial_init_secret.clone();
+
+            let commit_secret = random_bytes(key_size).into();
+            let psk_secret = PskSecret::new(&cs_provider);
+
+            let key_schedule_res = KeySchedule::from_key_schedule(
+                &key_schedule,
+                &commit_secret,
+                &group_context,
+                #[cfg(any(feature = "secret_tree_access", feature = "private_message"))]
+                32,
+                &psk_secret,
+                &cs_provider,
+            )
+            .unwrap();
+
+            key_schedule = key_schedule_res.key_schedule.clone();
+
+            let epoch1 = KeyScheduleEpoch::new(
+                key_schedule_res,
+                psk_secret,
+                commit_secret.to_vec(),
+                &group_context,
+                &cs_provider,
+            );
+
+            group_context.epoch += 1;
+            group_context.confirmed_transcript_hash = random_bytes(key_size).into();
+            group_context.tree_hash = random_bytes(key_size);
+
+            let commit_secret = random_bytes(key_size).into();
+            let psk_secret = PskSecret::new(&cs_provider);
+
+            let key_schedule_res = KeySchedule::from_key_schedule(
+                &key_schedule,
+                &commit_secret,
+                &group_context,
+                #[cfg(any(feature = "secret_tree_access", feature = "private_message"))]
+                32,
+                &psk_secret,
+                &cs_provider,
+            )
+            .unwrap();
+
+            let epoch2 = KeyScheduleEpoch::new(
+                key_schedule_res,
+                psk_secret,
+                commit_secret.to_vec(),
+                &group_context,
+                &cs_provider,
+            );
+
+            let test_case = TestCase {
+                cipher_suite: cs_provider.cipher_suite().into(),
+                group_id: group_context.group_id.clone(),
+                initial_init_secret: initial_init_secret.0.to_vec(),
+                epochs: vec![epoch1, epoch2],
+            };
+
+            test_cases.push(test_case);
+        }
+
+        test_cases
+    }
+
+    #[cfg(not(all(not(mls_build_async), feature = "rfc_compliant")))]
+    fn generate_test_vector() -> Vec<TestCase> {
+        panic!("Tests cannot be generated in async mode");
+    }
+
+    #[cfg(all(not(mls_build_async), feature = "rfc_compliant"))]
+    impl KeyScheduleEpoch {
+        #[cfg_attr(coverage_nightly, coverage(off))]
+        fn new<P: CipherSuiteProvider>(
+            key_schedule_res: KeyScheduleDerivationResult,
+            psk_secret: PskSecret,
+            commit_secret: Vec<u8>,
+            group_context: &GroupContext,
+            cs: &P,
+        ) -> Self {
+            let (_external_sec, external_pub) = key_schedule_res
+                .key_schedule
+                .get_external_key_pair(cs)
+                .unwrap();
+
+            let mut exporter = KeyScheduleExporter {
+                label: "exporter label 15".to_string(),
+                context: b"exporter context".to_vec(),
+                length: 64,
+                secret: vec![],
+            };
+
+            exporter.secret = key_schedule_res
+                .key_schedule
+                .export_secret(
+                    exporter.label.as_bytes(),
+                    &exporter.context,
+                    exporter.length,
+                    cs,
+                )
+                .unwrap()
+                .to_vec();
+
+            let welcome_secret =
+                get_welcome_secret(cs, &key_schedule_res.joiner_secret, &psk_secret)
+                    .unwrap()
+                    .to_vec();
+
+            KeyScheduleEpoch {
+                commit_secret,
+                welcome_secret,
+                psk_secret: psk_secret.to_vec(),
+                group_context: group_context.mls_encode_to_vec().unwrap(),
+                joiner_secret: key_schedule_res.joiner_secret.into(),
+                init_secret: key_schedule_res.key_schedule.init_secret.0.to_vec(),
+                sender_data_secret: key_schedule_res.epoch_secrets.sender_data_secret.to_vec(),
+                #[cfg(any(feature = "secret_tree_access", feature = "private_message"))]
+                encryption_secret: key_schedule_res.epoch_secrets.secret_tree.get_root_secret(),
+                exporter_secret: key_schedule_res.key_schedule.exporter_secret.to_vec(),
+                epoch_authenticator: key_schedule_res.key_schedule.authentication_secret.to_vec(),
+                external_secret: key_schedule_res.key_schedule.external_secret.to_vec(),
+                confirmation_key: key_schedule_res.confirmation_key.to_vec(),
+                membership_key: key_schedule_res.key_schedule.membership_key.to_vec(),
+                #[cfg(feature = "psk")]
+                resumption_psk: key_schedule_res.epoch_secrets.resumption_secret.to_vec(),
+                external_pub: external_pub.to_vec(),
+                exporter,
+                confirmed_transcript_hash: group_context.confirmed_transcript_hash.to_vec(),
+                tree_hash: group_context.tree_hash.clone(),
+            }
+        }
+    }
+
+    #[derive(Debug, serde::Serialize, serde::Deserialize)]
+    struct ExpandWithLabelTestCase {
+        #[serde(with = "hex::serde")]
+        secret: Vec<u8>,
+        label: String,
+        #[serde(with = "hex::serde")]
+        context: Vec<u8>,
+        length: usize,
+        #[serde(with = "hex::serde")]
+        out: Vec<u8>,
+    }
+
+    #[derive(Debug, serde::Serialize, serde::Deserialize)]
+    struct DeriveSecretTestCase {
+        #[serde(with = "hex::serde")]
+        secret: Vec<u8>,
+        label: String,
+        #[serde(with = "hex::serde")]
+        out: Vec<u8>,
+    }
+
+    #[derive(Debug, serde::Serialize, serde::Deserialize)]
+    pub struct InteropTestCase {
+        cipher_suite: u16,
+        expand_with_label: ExpandWithLabelTestCase,
+        derive_secret: DeriveSecretTestCase,
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_basic_crypto_test_vectors() {
+        // The test vector can be found here https://github.com/mlswg/mls-implementations/blob/main/test-vectors/crypto-basics.json
+        let test_cases: Vec<InteropTestCase> =
+            load_test_case_json!(basic_crypto, Vec::<InteropTestCase>::new());
+
+        for test_case in test_cases {
+            if let Some(cs) = try_test_cipher_suite_provider(test_case.cipher_suite) {
+                let test_exp = &test_case.expand_with_label;
+
+                let computed = kdf_expand_with_label(
+                    &cs,
+                    &test_exp.secret,
+                    test_exp.label.as_bytes(),
+                    &test_exp.context,
+                    Some(test_exp.length),
+                )
+                .await
+                .unwrap();
+
+                assert_eq!(&computed.to_vec(), &test_exp.out);
+
+                let test_derive = &test_case.derive_secret;
+
+                let computed =
+                    kdf_derive_secret(&cs, &test_derive.secret, test_derive.label.as_bytes())
+                        .await
+                        .unwrap();
+
+                assert_eq!(&computed.to_vec(), &test_derive.out);
+            }
+        }
+    }
+}
diff --git a/src/group/membership_tag.rs b/src/group/membership_tag.rs
new file mode 100644
index 0000000..b28edea
--- /dev/null
+++ b/src/group/membership_tag.rs
@@ -0,0 +1,163 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// Copyright by contributors to this project.
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+
+use crate::client::MlsError;
+use crate::crypto::CipherSuiteProvider;
+use crate::group::message_signature::{AuthenticatedContentTBS, FramedContentAuthData};
+use crate::group::GroupContext;
+use alloc::vec::Vec;
+use core::{
+    fmt::{self, Debug},
+    ops::Deref,
+};
+use mls_rs_codec::{MlsDecode, MlsEncode, MlsSize};
+use mls_rs_core::error::IntoAnyError;
+
+use super::message_signature::AuthenticatedContent;
+
+#[derive(Clone, Debug, PartialEq, MlsSize, MlsEncode)]
+struct AuthenticatedContentTBM<'a> {
+    content_tbs: AuthenticatedContentTBS<'a>,
+    auth: &'a FramedContentAuthData,
+}
+
+impl<'a> AuthenticatedContentTBM<'a> {
+    pub fn from_authenticated_content(
+        auth_content: &'a AuthenticatedContent,
+        group_context: &'a GroupContext,
+    ) -> AuthenticatedContentTBM<'a> {
+        AuthenticatedContentTBM {
+            content_tbs: AuthenticatedContentTBS::from_authenticated_content(
+                auth_content,
+                Some(group_context),
+                group_context.protocol_version,
+            ),
+            auth: &auth_content.auth,
+        }
+    }
+}
+
+#[derive(Clone, PartialEq, MlsSize, MlsEncode, MlsDecode)]
+#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
+pub struct MembershipTag(#[mls_codec(with = "mls_rs_codec::byte_vec")] Vec<u8>);
+
+impl Debug for MembershipTag {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        mls_rs_core::debug::pretty_bytes(&self.0)
+            .named("MembershipTag")
+            .fmt(f)
+    }
+}
+
+impl Deref for MembershipTag {
+    type Target = Vec<u8>;
+
+    fn deref(&self) -> &Self::Target {
+        &self.0
+    }
+}
+
+impl From<Vec<u8>> for MembershipTag {
+    fn from(m: Vec<u8>) -> Self {
+        Self(m)
+    }
+}
+
+impl MembershipTag {
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(crate) async fn create<P: CipherSuiteProvider>(
+        authenticated_content: &AuthenticatedContent,
+        group_context: &GroupContext,
+        membership_key: &[u8],
+        cipher_suite_provider: &P,
+    ) -> Result<Self, MlsError> {
+        let plaintext_tbm = AuthenticatedContentTBM::from_authenticated_content(
+            authenticated_content,
+            group_context,
+        );
+
+        let serialized_tbm = plaintext_tbm.mls_encode_to_vec()?;
+
+        let tag = cipher_suite_provider
+            .mac(membership_key, &serialized_tbm)
+            .await
+            .map_err(|e| MlsError::CryptoProviderError(e.into_any_error()))?;
+
+        Ok(MembershipTag(tag))
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::crypto::test_utils::{test_cipher_suite_provider, try_test_cipher_suite_provider};
+    use crate::group::{
+        framing::test_utils::get_test_auth_content, test_utils::get_test_group_context,
+    };
+
+    #[cfg(not(mls_build_async))]
+    use crate::crypto::test_utils::TestCryptoProvider;
+
+    #[cfg(target_arch = "wasm32")]
+    use wasm_bindgen_test::wasm_bindgen_test as test;
+
+    #[derive(Debug, serde::Serialize, serde::Deserialize)]
+    struct TestCase {
+        cipher_suite: u16,
+        #[serde(with = "hex::serde")]
+        tag: Vec<u8>,
+    }
+
+    #[cfg(not(mls_build_async))]
+    #[cfg_attr(coverage_nightly, coverage(off))]
+    fn generate_test_cases() -> Vec<TestCase> {
+        let mut test_cases = Vec::new();
+
+        for cipher_suite in TestCryptoProvider::all_supported_cipher_suites() {
+            let tag = MembershipTag::create(
+                &get_test_auth_content(),
+                &get_test_group_context(1, cipher_suite),
+                b"membership_key".as_ref(),
+                &test_cipher_suite_provider(cipher_suite),
+            )
+            .unwrap();
+
+            test_cases.push(TestCase {
+                cipher_suite: cipher_suite.into(),
+                tag: tag.to_vec(),
+            });
+        }
+
+        test_cases
+    }
+
+    #[cfg(mls_build_async)]
+    fn generate_test_cases() -> Vec<TestCase> {
+        panic!("Tests cannot be generated in async mode");
+    }
+
+    fn load_test_cases() -> Vec<TestCase> {
+        load_test_case_json!(membership_tag, generate_test_cases())
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_membership_tag() {
+        for case in load_test_cases() {
+            let Some(cs_provider) = try_test_cipher_suite_provider(case.cipher_suite) else {
+                continue;
+            };
+
+            let tag = MembershipTag::create(
+                &get_test_auth_content(),
+                &get_test_group_context(1, cs_provider.cipher_suite()).await,
+                b"membership_key".as_ref(),
+                &test_cipher_suite_provider(cs_provider.cipher_suite()),
+            )
+            .await
+            .unwrap();
+
+            assert_eq!(**tag, case.tag);
+        }
+    }
+}
diff --git a/src/group/message_processor.rs b/src/group/message_processor.rs
new file mode 100644
index 0000000..8084a58
--- /dev/null
+++ b/src/group/message_processor.rs
@@ -0,0 +1,1039 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// Copyright by contributors to this project.
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+
+use super::{
+    commit_sender,
+    confirmation_tag::ConfirmationTag,
+    framing::{
+        ApplicationData, Content, ContentType, MlsMessage, MlsMessagePayload, PublicMessage, Sender,
+    },
+    message_signature::AuthenticatedContent,
+    mls_rules::{CommitDirection, MlsRules},
+    proposal_filter::ProposalBundle,
+    state::GroupState,
+    transcript_hash::InterimTranscriptHash,
+    transcript_hashes, validate_group_info_member, GroupContext, GroupInfo, Welcome,
+};
+use crate::{
+    client::MlsError,
+    key_package::validate_key_package_properties,
+    time::MlsTime,
+    tree_kem::{
+        leaf_node_validator::{LeafNodeValidator, ValidationContext},
+        node::LeafIndex,
+        path_secret::PathSecret,
+        validate_update_path, TreeKemPrivate, TreeKemPublic, ValidatedUpdatePath,
+    },
+    CipherSuiteProvider, KeyPackage,
+};
+#[cfg(mls_build_async)]
+use alloc::boxed::Box;
+use alloc::vec::Vec;
+use core::fmt::{self, Debug};
+use mls_rs_core::{
+    identity::IdentityProvider, protocol_version::ProtocolVersion, psk::PreSharedKeyStorage,
+};
+
+#[cfg(feature = "by_ref_proposal")]
+use super::proposal_ref::ProposalRef;
+
+#[cfg(not(feature = "by_ref_proposal"))]
+use crate::group::proposal_cache::resolve_for_commit;
+
+#[cfg(feature = "by_ref_proposal")]
+use super::proposal::Proposal;
+
+#[cfg(feature = "custom_proposal")]
+use super::proposal_filter::ProposalInfo;
+
+#[cfg(feature = "state_update")]
+use mls_rs_core::{
+    crypto::CipherSuite,
+    group::{MemberUpdate, RosterUpdate},
+};
+
+#[cfg(all(feature = "state_update", feature = "psk"))]
+use mls_rs_core::psk::ExternalPskId;
+
+#[cfg(feature = "state_update")]
+use crate::tree_kem::UpdatePath;
+
+#[cfg(feature = "state_update")]
+use super::{member_from_key_package, member_from_leaf_node};
+
+#[cfg(all(feature = "state_update", feature = "custom_proposal"))]
+use super::proposal::CustomProposal;
+
+#[cfg(feature = "private_message")]
+use crate::group::framing::PrivateMessage;
+
+#[cfg(feature = "by_ref_proposal")]
+use mls_rs_codec::{MlsDecode, MlsEncode, MlsSize};
+
+#[derive(Debug)]
+pub(crate) struct ProvisionalState {
+    pub(crate) public_tree: TreeKemPublic,
+    pub(crate) applied_proposals: ProposalBundle,
+    pub(crate) group_context: GroupContext,
+    pub(crate) external_init_index: Option<LeafIndex>,
+    pub(crate) indexes_of_added_kpkgs: Vec<LeafIndex>,
+    #[cfg(feature = "by_ref_proposal")]
+    pub(crate) unused_proposals: Vec<crate::mls_rules::ProposalInfo<Proposal>>,
+}
+
+//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(proposals: &ProposalBundle) -> bool {
+    let res = proposals.external_init_proposals().first().is_some();
+
+    #[cfg(feature = "by_ref_proposal")]
+    let res = res || !proposals.update_proposals().is_empty();
+
+    res || proposals.length() == 0
+        || proposals.group_context_extensions_proposal().is_some()
+        || !proposals.remove_proposals().is_empty()
+}
+
+/// Representation of changes made by a [commit](crate::Group::commit).
+#[cfg(feature = "state_update")]
+#[derive(Clone, Debug, PartialEq)]
+pub struct StateUpdate {
+    pub(crate) roster_update: RosterUpdate,
+    #[cfg(feature = "psk")]
+    pub(crate) added_psks: Vec<ExternalPskId>,
+    pub(crate) pending_reinit: Option<CipherSuite>,
+    pub(crate) active: bool,
+    pub(crate) epoch: u64,
+    #[cfg(feature = "custom_proposal")]
+    pub(crate) custom_proposals: Vec<ProposalInfo<CustomProposal>>,
+    #[cfg(feature = "by_ref_proposal")]
+    pub(crate) unused_proposals: Vec<crate::mls_rules::ProposalInfo<Proposal>>,
+}
+
+#[cfg(not(feature = "state_update"))]
+#[non_exhaustive]
+#[derive(Clone, Debug, PartialEq)]
+pub struct StateUpdate {}
+
+#[cfg(feature = "state_update")]
+impl StateUpdate {
+    /// Changes to the roster as a result of proposals.
+    pub fn roster_update(&self) -> &RosterUpdate {
+        &self.roster_update
+    }
+
+    #[cfg(feature = "psk")]
+    /// Pre-shared keys that have been added to the group.
+    pub fn added_psks(&self) -> &[ExternalPskId] {
+        &self.added_psks
+    }
+
+    /// Flag to indicate if the group is now pending reinitialization due to
+    /// receiving a [`ReInit`](crate::group::proposal::Proposal::ReInit)
+    /// proposal.
+    pub fn is_pending_reinit(&self) -> bool {
+        self.pending_reinit.is_some()
+    }
+
+    /// Flag to indicate the group is still active. This will be false if the
+    /// member processing the commit has been removed from the group.
+    pub fn is_active(&self) -> bool {
+        self.active
+    }
+
+    /// The new epoch of the group state.
+    pub fn new_epoch(&self) -> u64 {
+        self.epoch
+    }
+
+    /// Custom proposals that were committed to.
+    #[cfg(feature = "custom_proposal")]
+    pub fn custom_proposals(&self) -> &[ProposalInfo<CustomProposal>] {
+        &self.custom_proposals
+    }
+
+    /// Proposals that were received in the prior epoch but not committed to.
+    #[cfg(feature = "by_ref_proposal")]
+    pub fn unused_proposals(&self) -> &[crate::mls_rules::ProposalInfo<Proposal>] {
+        &self.unused_proposals
+    }
+
+    pub fn pending_reinit_ciphersuite(&self) -> Option<CipherSuite> {
+        self.pending_reinit
+    }
+}
+
+#[cfg_attr(
+    all(feature = "ffi", not(test)),
+    safer_ffi_gen::ffi_type(clone, opaque)
+)]
+#[derive(Debug, Clone)]
+#[allow(clippy::large_enum_variant)]
+/// An event generated as a result of processing a message for a group with
+/// [`Group::process_incoming_message`](crate::group::Group::process_incoming_message).
+pub enum ReceivedMessage {
+    /// An application message was decrypted.
+    ApplicationMessage(ApplicationMessageDescription),
+    /// A new commit was processed creating a new group state.
+    Commit(CommitMessageDescription),
+    /// A proposal was received.
+    Proposal(ProposalMessageDescription),
+    /// Validated GroupInfo object
+    GroupInfo(GroupInfo),
+    /// Validated welcome message
+    Welcome,
+    /// Validated key package
+    KeyPackage(KeyPackage),
+}
+
+impl TryFrom<ApplicationMessageDescription> for ReceivedMessage {
+    type Error = MlsError;
+
+    fn try_from(value: ApplicationMessageDescription) -> Result<Self, Self::Error> {
+        Ok(ReceivedMessage::ApplicationMessage(value))
+    }
+}
+
+impl From<CommitMessageDescription> for ReceivedMessage {
+    fn from(value: CommitMessageDescription) -> Self {
+        ReceivedMessage::Commit(value)
+    }
+}
+
+impl From<ProposalMessageDescription> for ReceivedMessage {
+    fn from(value: ProposalMessageDescription) -> Self {
+        ReceivedMessage::Proposal(value)
+    }
+}
+
+impl From<GroupInfo> for ReceivedMessage {
+    fn from(value: GroupInfo) -> Self {
+        ReceivedMessage::GroupInfo(value)
+    }
+}
+
+impl From<Welcome> for ReceivedMessage {
+    fn from(_: Welcome) -> Self {
+        ReceivedMessage::Welcome
+    }
+}
+
+impl From<KeyPackage> for ReceivedMessage {
+    fn from(value: KeyPackage) -> Self {
+        ReceivedMessage::KeyPackage(value)
+    }
+}
+
+#[cfg_attr(
+    all(feature = "ffi", not(test)),
+    safer_ffi_gen::ffi_type(clone, opaque)
+)]
+#[derive(Clone, PartialEq, Eq)]
+/// Description of a MLS application message.
+pub struct ApplicationMessageDescription {
+    /// Index of this user in the group state.
+    pub sender_index: u32,
+    /// Received application data.
+    data: ApplicationData,
+    /// Plaintext authenticated data in the received MLS packet.
+    pub authenticated_data: Vec<u8>,
+}
+
+impl Debug for ApplicationMessageDescription {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.debug_struct("ApplicationMessageDescription")
+            .field("sender_index", &self.sender_index)
+            .field("data", &self.data)
+            .field(
+                "authenticated_data",
+                &mls_rs_core::debug::pretty_bytes(&self.authenticated_data),
+            )
+            .finish()
+    }
+}
+
+#[cfg_attr(all(feature = "ffi", not(test)), safer_ffi_gen::safer_ffi_gen)]
+impl ApplicationMessageDescription {
+    pub fn data(&self) -> &[u8] {
+        self.data.as_bytes()
+    }
+}
+
+#[cfg_attr(
+    all(feature = "ffi", not(test)),
+    safer_ffi_gen::ffi_type(clone, opaque)
+)]
+#[derive(Clone, PartialEq)]
+#[non_exhaustive]
+/// Description of a processed MLS commit message.
+pub struct CommitMessageDescription {
+    /// True if this is the result of an external commit.
+    pub is_external: bool,
+    /// The index in the group state of the member who performed this commit.
+    pub committer: u32,
+    /// A full description of group state changes as a result of this commit.
+    pub state_update: StateUpdate,
+    /// Plaintext authenticated data in the received MLS packet.
+    pub authenticated_data: Vec<u8>,
+}
+
+impl Debug for CommitMessageDescription {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.debug_struct("CommitMessageDescription")
+            .field("is_external", &self.is_external)
+            .field("committer", &self.committer)
+            .field("state_update", &self.state_update)
+            .field(
+                "authenticated_data",
+                &mls_rs_core::debug::pretty_bytes(&self.authenticated_data),
+            )
+            .finish()
+    }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+/// Proposal sender type.
+pub enum ProposalSender {
+    /// A current member of the group by index in the group state.
+    Member(u32),
+    /// An external entity by index within an
+    /// [`ExternalSendersExt`](crate::extension::built_in::ExternalSendersExt).
+    External(u32),
+    /// A new member proposing their addition to the group.
+    NewMember,
+}
+
+impl TryFrom<Sender> for ProposalSender {
+    type Error = MlsError;
+
+    fn try_from(value: Sender) -> Result<Self, Self::Error> {
+        match value {
+            Sender::Member(index) => Ok(Self::Member(index)),
+            #[cfg(feature = "by_ref_proposal")]
+            Sender::External(index) => Ok(Self::External(index)),
+            #[cfg(feature = "by_ref_proposal")]
+            Sender::NewMemberProposal => Ok(Self::NewMember),
+            Sender::NewMemberCommit => Err(MlsError::InvalidSender),
+        }
+    }
+}
+
+#[cfg(feature = "by_ref_proposal")]
+#[cfg_attr(
+    all(feature = "ffi", not(test)),
+    safer_ffi_gen::ffi_type(clone, opaque)
+)]
+#[derive(Clone)]
+#[non_exhaustive]
+/// Description of a processed MLS proposal message.
+pub struct ProposalMessageDescription {
+    /// Sender of the proposal.
+    pub sender: ProposalSender,
+    /// Proposal content.
+    pub proposal: Proposal,
+    /// Plaintext authenticated data in the received MLS packet.
+    pub authenticated_data: Vec<u8>,
+    /// Proposal reference.
+    pub proposal_ref: ProposalRef,
+}
+
+#[cfg(feature = "by_ref_proposal")]
+impl Debug for ProposalMessageDescription {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.debug_struct("ProposalMessageDescription")
+            .field("sender", &self.sender)
+            .field("proposal", &self.proposal)
+            .field(
+                "authenticated_data",
+                &mls_rs_core::debug::pretty_bytes(&self.authenticated_data),
+            )
+            .field("proposal_ref", &self.proposal_ref)
+            .finish()
+    }
+}
+
+#[cfg(feature = "by_ref_proposal")]
+#[derive(MlsSize, MlsEncode, MlsDecode)]
+pub struct CachedProposal {
+    pub(crate) proposal: Proposal,
+    pub(crate) proposal_ref: ProposalRef,
+    pub(crate) sender: Sender,
+}
+
+#[cfg(feature = "by_ref_proposal")]
+impl CachedProposal {
+    /// Deserialize the proposal
+    pub fn from_bytes(bytes: &[u8]) -> Result<Self, MlsError> {
+        Ok(Self::mls_decode(&mut &*bytes)?)
+    }
+
+    /// Serialize the proposal
+    pub fn to_bytes(&self) -> Result<Vec<u8>, MlsError> {
+        Ok(self.mls_encode_to_vec()?)
+    }
+}
+
+#[cfg(feature = "by_ref_proposal")]
+impl ProposalMessageDescription {
+    pub fn cached_proposal(self) -> CachedProposal {
+        let sender = match self.sender {
+            ProposalSender::Member(i) => Sender::Member(i),
+            ProposalSender::External(i) => Sender::External(i),
+            ProposalSender::NewMember => Sender::NewMemberProposal,
+        };
+
+        CachedProposal {
+            proposal: self.proposal,
+            proposal_ref: self.proposal_ref,
+            sender,
+        }
+    }
+
+    pub fn proposal_ref(&self) -> Vec<u8> {
+        self.proposal_ref.to_vec()
+    }
+}
+
+#[cfg(not(feature = "by_ref_proposal"))]
+#[cfg_attr(
+    all(feature = "ffi", not(test)),
+    safer_ffi_gen::ffi_type(clone, opaque)
+)]
+#[derive(Debug, Clone)]
+/// Description of a processed MLS proposal message.
+pub struct ProposalMessageDescription {}
+
+#[allow(clippy::large_enum_variant)]
+pub(crate) enum EventOrContent<E> {
+    #[cfg_attr(
+        not(all(feature = "private_message", feature = "external_client")),
+        allow(dead_code)
+    )]
+    Event(E),
+    Content(AuthenticatedContent),
+}
+
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+#[cfg_attr(all(target_arch = "wasm32", mls_build_async), maybe_async::must_be_async(?Send))]
+#[cfg_attr(
+    all(not(target_arch = "wasm32"), mls_build_async),
+    maybe_async::must_be_async
+)]
+pub(crate) trait MessageProcessor: Send + Sync {
+    type OutputType: TryFrom<ApplicationMessageDescription, Error = MlsError>
+        + From<CommitMessageDescription>
+        + From<ProposalMessageDescription>
+        + From<GroupInfo>
+        + From<Welcome>
+        + From<KeyPackage>
+        + Send;
+
+    type MlsRules: MlsRules;
+    type IdentityProvider: IdentityProvider;
+    type CipherSuiteProvider: CipherSuiteProvider;
+    type PreSharedKeyStorage: PreSharedKeyStorage;
+
+    async fn process_incoming_message(
+        &mut self,
+        message: MlsMessage,
+        #[cfg(feature = "by_ref_proposal")] cache_proposal: bool,
+    ) -> Result<Self::OutputType, MlsError> {
+        self.process_incoming_message_with_time(
+            message,
+            #[cfg(feature = "by_ref_proposal")]
+            cache_proposal,
+            None,
+        )
+        .await
+    }
+
+    async fn process_incoming_message_with_time(
+        &mut self,
+        message: MlsMessage,
+        #[cfg(feature = "by_ref_proposal")] cache_proposal: bool,
+        time_sent: Option<MlsTime>,
+    ) -> Result<Self::OutputType, MlsError> {
+        let event_or_content = self.get_event_from_incoming_message(message).await?;
+
+        self.process_event_or_content(
+            event_or_content,
+            #[cfg(feature = "by_ref_proposal")]
+            cache_proposal,
+            time_sent,
+        )
+        .await
+    }
+
+    async fn get_event_from_incoming_message(
+        &mut self,
+        message: MlsMessage,
+    ) -> Result<EventOrContent<Self::OutputType>, MlsError> {
+        self.check_metadata(&message)?;
+
+        match message.payload {
+            MlsMessagePayload::Plain(plaintext) => {
+                self.verify_plaintext_authentication(plaintext).await
+            }
+            #[cfg(feature = "private_message")]
+            MlsMessagePayload::Cipher(cipher_text) => self.process_ciphertext(&cipher_text).await,
+            MlsMessagePayload::GroupInfo(group_info) => {
+                validate_group_info_member(
+                    self.group_state(),
+                    message.version,
+                    &group_info,
+                    self.cipher_suite_provider(),
+                )
+                .await?;
+
+                Ok(EventOrContent::Event(group_info.into()))
+            }
+            MlsMessagePayload::Welcome(welcome) => {
+                self.validate_welcome(&welcome, message.version)?;
+
+                Ok(EventOrContent::Event(welcome.into()))
+            }
+            MlsMessagePayload::KeyPackage(key_package) => {
+                self.validate_key_package(&key_package, message.version)
+                    .await?;
+
+                Ok(EventOrContent::Event(key_package.into()))
+            }
+        }
+    }
+
+    async fn process_event_or_content(
+        &mut self,
+        event_or_content: EventOrContent<Self::OutputType>,
+        #[cfg(feature = "by_ref_proposal")] cache_proposal: bool,
+        time_sent: Option<MlsTime>,
+    ) -> Result<Self::OutputType, MlsError> {
+        let msg = match event_or_content {
+            EventOrContent::Event(event) => event,
+            EventOrContent::Content(content) => {
+                self.process_auth_content(
+                    content,
+                    #[cfg(feature = "by_ref_proposal")]
+                    cache_proposal,
+                    time_sent,
+                )
+                .await?
+            }
+        };
+
+        Ok(msg)
+    }
+
+    async fn process_auth_content(
+        &mut self,
+        auth_content: AuthenticatedContent,
+        #[cfg(feature = "by_ref_proposal")] cache_proposal: bool,
+        time_sent: Option<MlsTime>,
+    ) -> Result<Self::OutputType, MlsError> {
+        let event = match auth_content.content.content {
+            #[cfg(feature = "private_message")]
+            Content::Application(data) => {
+                let authenticated_data = auth_content.content.authenticated_data;
+                let sender = auth_content.content.sender;
+
+                self.process_application_message(data, sender, authenticated_data)
+                    .and_then(Self::OutputType::try_from)
+            }
+            Content::Commit(_) => self
+                .process_commit(auth_content, time_sent)
+                .await
+                .map(Self::OutputType::from),
+            #[cfg(feature = "by_ref_proposal")]
+            Content::Proposal(ref proposal) => self
+                .process_proposal(&auth_content, proposal, cache_proposal)
+                .await
+                .map(Self::OutputType::from),
+        }?;
+
+        Ok(event)
+    }
+
+    #[cfg(feature = "private_message")]
+    fn process_application_message(
+        &self,
+        data: ApplicationData,
+        sender: Sender,
+        authenticated_data: Vec<u8>,
+    ) -> Result<ApplicationMessageDescription, MlsError> {
+        let Sender::Member(sender_index) = sender else {
+            return Err(MlsError::InvalidSender);
+        };
+
+        Ok(ApplicationMessageDescription {
+            authenticated_data,
+            sender_index,
+            data,
+        })
+    }
+
+    #[cfg(feature = "by_ref_proposal")]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn process_proposal(
+        &mut self,
+        auth_content: &AuthenticatedContent,
+        proposal: &Proposal,
+        cache_proposal: bool,
+    ) -> Result<ProposalMessageDescription, MlsError> {
+        let proposal_ref =
+            ProposalRef::from_content(self.cipher_suite_provider(), auth_content).await?;
+
+        let group_state = self.group_state_mut();
+
+        if cache_proposal {
+            let proposal_ref = proposal_ref.clone();
+
+            group_state.proposals.insert(
+                proposal_ref.clone(),
+                proposal.clone(),
+                auth_content.content.sender,
+            );
+        }
+
+        Ok(ProposalMessageDescription {
+            authenticated_data: auth_content.content.authenticated_data.clone(),
+            proposal: proposal.clone(),
+            sender: auth_content.content.sender.try_into()?,
+            proposal_ref,
+        })
+    }
+
+    #[cfg(feature = "state_update")]
+    async fn make_state_update(
+        &self,
+        provisional: &ProvisionalState,
+        path: Option<&UpdatePath>,
+        sender: LeafIndex,
+    ) -> Result<StateUpdate, MlsError> {
+        let added = provisional
+            .applied_proposals
+            .additions
+            .iter()
+            .zip(provisional.indexes_of_added_kpkgs.iter())
+            .map(|(p, index)| member_from_key_package(&p.proposal.key_package, *index))
+            .collect::<Vec<_>>();
+
+        let mut added = added;
+
+        let old_tree = &self.group_state().public_tree;
+
+        let removed = provisional
+            .applied_proposals
+            .removals
+            .iter()
+            .map(|p| {
+                let index = p.proposal.to_remove;
+                let node = old_tree.nodes.borrow_as_leaf(index)?;
+                Ok(member_from_leaf_node(node, index))
+            })
+            .collect::<Result<_, MlsError>>()?;
+
+        #[cfg(feature = "by_ref_proposal")]
+        let mut updated = provisional
+            .applied_proposals
+            .update_senders
+            .iter()
+            .map(|index| {
+                let prior = old_tree
+                    .get_leaf_node(*index)
+                    .map(|n| member_from_leaf_node(n, *index))?;
+
+                let new = provisional
+                    .public_tree
+                    .get_leaf_node(*index)
+                    .map(|n| member_from_leaf_node(n, *index))?;
+
+                Ok::<_, MlsError>(MemberUpdate::new(prior, new))
+            })
+            .collect::<Result<Vec<_>, _>>()?;
+
+        #[cfg(not(feature = "by_ref_proposal"))]
+        let mut updated = Vec::new();
+
+        if let Some(path) = path {
+            if !provisional
+                .applied_proposals
+                .external_initializations
+                .is_empty()
+            {
+                added.push(member_from_leaf_node(&path.leaf_node, sender))
+            } else {
+                let prior = old_tree
+                    .get_leaf_node(sender)
+                    .map(|n| member_from_leaf_node(n, sender))?;
+
+                let new = member_from_leaf_node(&path.leaf_node, sender);
+
+                updated.push(MemberUpdate::new(prior, new))
+            }
+        }
+
+        #[cfg(feature = "psk")]
+        let psks = provisional
+            .applied_proposals
+            .psks
+            .iter()
+            .filter_map(|psk| psk.proposal.external_psk_id().cloned())
+            .collect::<Vec<_>>();
+
+        let roster_update = RosterUpdate::new(added, removed, updated);
+
+        let update = StateUpdate {
+            roster_update,
+            #[cfg(feature = "psk")]
+            added_psks: psks,
+            pending_reinit: provisional
+                .applied_proposals
+                .reinitializations
+                .first()
+                .map(|ri| ri.proposal.new_cipher_suite()),
+            active: true,
+            epoch: provisional.group_context.epoch,
+            #[cfg(feature = "custom_proposal")]
+            custom_proposals: provisional.applied_proposals.custom_proposals.clone(),
+            #[cfg(feature = "by_ref_proposal")]
+            unused_proposals: provisional.unused_proposals.clone(),
+        };
+
+        Ok(update)
+    }
+
+    async fn process_commit(
+        &mut self,
+        auth_content: AuthenticatedContent,
+        time_sent: Option<MlsTime>,
+    ) -> Result<CommitMessageDescription, MlsError> {
+        if self.group_state().pending_reinit.is_some() {
+            return Err(MlsError::GroupUsedAfterReInit);
+        }
+
+        // Update the new GroupContext's confirmed and interim transcript hashes using the new Commit.
+        let (interim_transcript_hash, confirmed_transcript_hash) = transcript_hashes(
+            self.cipher_suite_provider(),
+            &self.group_state().interim_transcript_hash,
+            &auth_content,
+        )
+        .await?;
+
+        #[cfg(any(feature = "private_message", feature = "by_ref_proposal"))]
+        let commit = match auth_content.content.content {
+            Content::Commit(commit) => Ok(commit),
+            _ => Err(MlsError::UnexpectedMessageType),
+        }?;
+
+        #[cfg(not(any(feature = "private_message", feature = "by_ref_proposal")))]
+        let Content::Commit(commit) = auth_content.content.content;
+
+        let group_state = self.group_state();
+        let id_provider = self.identity_provider();
+
+        #[cfg(feature = "by_ref_proposal")]
+        let proposals = group_state
+            .proposals
+            .resolve_for_commit(auth_content.content.sender, commit.proposals)?;
+
+        #[cfg(not(feature = "by_ref_proposal"))]
+        let proposals = resolve_for_commit(auth_content.content.sender, commit.proposals)?;
+
+        let mut provisional_state = group_state
+            .apply_resolved(
+                auth_content.content.sender,
+                proposals,
+                commit.path.as_ref().map(|path| &path.leaf_node),
+                &id_provider,
+                self.cipher_suite_provider(),
+                &self.psk_storage(),
+                &self.mls_rules(),
+                time_sent,
+                CommitDirection::Receive,
+            )
+            .await?;
+
+        let sender = commit_sender(&auth_content.content.sender, &provisional_state)?;
+
+        #[cfg(feature = "state_update")]
+        let mut state_update = self
+            .make_state_update(&provisional_state, commit.path.as_ref(), sender)
+            .await?;
+
+        #[cfg(not(feature = "state_update"))]
+        let state_update = StateUpdate {};
+
+        //Verify that the path value is populated if the proposals vector contains any Update
+        // or Remove proposals, or if it's empty. Otherwise, the path value MAY be omitted.
+        if path_update_required(&provisional_state.applied_proposals) && commit.path.is_none() {
+            return Err(MlsError::CommitMissingPath);
+        }
+
+        if !self.can_continue_processing(&provisional_state) {
+            #[cfg(feature = "state_update")]
+            {
+                state_update.active = false;
+            }
+
+            return Ok(CommitMessageDescription {
+                is_external: matches!(auth_content.content.sender, Sender::NewMemberCommit),
+                authenticated_data: auth_content.content.authenticated_data,
+                committer: *sender,
+                state_update,
+            });
+        }
+
+        let update_path = match commit.path {
+            Some(update_path) => Some(
+                validate_update_path(
+                    &self.identity_provider(),
+                    self.cipher_suite_provider(),
+                    update_path,
+                    &provisional_state,
+                    sender,
+                    time_sent,
+                )
+                .await?,
+            ),
+            None => None,
+        };
+
+        let new_secrets = match update_path {
+            Some(update_path) => {
+                self.apply_update_path(sender, &update_path, &mut provisional_state)
+                    .await
+            }
+            None => Ok(None),
+        }?;
+
+        // Update the transcript hash to get the new context.
+        provisional_state.group_context.confirmed_transcript_hash = confirmed_transcript_hash;
+
+        // Update the parent hashes in the new context
+        provisional_state
+            .public_tree
+            .update_hashes(&[sender], self.cipher_suite_provider())
+            .await?;
+
+        // Update the tree hash in the new context
+        provisional_state.group_context.tree_hash = provisional_state
+            .public_tree
+            .tree_hash(self.cipher_suite_provider())
+            .await?;
+
+        if let Some(reinit) = provisional_state.applied_proposals.reinitializations.pop() {
+            self.group_state_mut().pending_reinit = Some(reinit.proposal);
+
+            #[cfg(feature = "state_update")]
+            {
+                state_update.active = false;
+            }
+        }
+
+        if let Some(confirmation_tag) = &auth_content.auth.confirmation_tag {
+            // Update the key schedule to calculate new private keys
+            self.update_key_schedule(
+                new_secrets,
+                interim_transcript_hash,
+                confirmation_tag,
+                provisional_state,
+            )
+            .await?;
+
+            Ok(CommitMessageDescription {
+                is_external: matches!(auth_content.content.sender, Sender::NewMemberCommit),
+                authenticated_data: auth_content.content.authenticated_data,
+                committer: *sender,
+                state_update,
+            })
+        } else {
+            Err(MlsError::InvalidConfirmationTag)
+        }
+    }
+
+    fn group_state(&self) -> &GroupState;
+    fn group_state_mut(&mut self) -> &mut GroupState;
+    #[cfg(feature = "private_message")]
+    fn self_index(&self) -> Option<LeafIndex>;
+    fn mls_rules(&self) -> Self::MlsRules;
+    fn identity_provider(&self) -> Self::IdentityProvider;
+    fn cipher_suite_provider(&self) -> &Self::CipherSuiteProvider;
+    fn psk_storage(&self) -> Self::PreSharedKeyStorage;
+    fn can_continue_processing(&self, provisional_state: &ProvisionalState) -> bool;
+
+    #[cfg(feature = "private_message")]
+    fn min_epoch_available(&self) -> Option<u64>;
+
+    fn check_metadata(&self, message: &MlsMessage) -> Result<(), MlsError> {
+        let context = &self.group_state().context;
+
+        if message.version != context.protocol_version {
+            return Err(MlsError::ProtocolVersionMismatch);
+        }
+
+        if let Some((group_id, epoch, content_type)) = match &message.payload {
+            MlsMessagePayload::Plain(plaintext) => Some((
+                &plaintext.content.group_id,
+                plaintext.content.epoch,
+                plaintext.content.content_type(),
+            )),
+            #[cfg(feature = "private_message")]
+            MlsMessagePayload::Cipher(ciphertext) => Some((
+                &ciphertext.group_id,
+                ciphertext.epoch,
+                ciphertext.content_type,
+            )),
+            _ => None,
+        } {
+            if group_id != &context.group_id {
+                return Err(MlsError::GroupIdMismatch);
+            }
+
+            match content_type {
+                ContentType::Commit => {
+                    if context.epoch != epoch {
+                        Err(MlsError::InvalidEpoch)
+                    } else {
+                        Ok(())
+                    }
+                }
+                #[cfg(feature = "by_ref_proposal")]
+                ContentType::Proposal => {
+                    if context.epoch != epoch {
+                        Err(MlsError::InvalidEpoch)
+                    } else {
+                        Ok(())
+                    }
+                }
+                #[cfg(feature = "private_message")]
+                ContentType::Application => {
+                    if let Some(min) = self.min_epoch_available() {
+                        if epoch < min {
+                            Err(MlsError::InvalidEpoch)
+                        } else {
+                            Ok(())
+                        }
+                    } else {
+                        Ok(())
+                    }
+                }
+            }?;
+
+            // Proposal and commit messages must be sent in the current epoch
+            let check_epoch = content_type == ContentType::Commit;
+
+            #[cfg(feature = "by_ref_proposal")]
+            let check_epoch = check_epoch || content_type == ContentType::Proposal;
+
+            if check_epoch && epoch != context.epoch {
+                return Err(MlsError::InvalidEpoch);
+            }
+
+            // Unencrypted application messages are not allowed
+            #[cfg(feature = "private_message")]
+            if !matches!(&message.payload, MlsMessagePayload::Cipher(_))
+                && content_type == ContentType::Application
+            {
+                return Err(MlsError::UnencryptedApplicationMessage);
+            }
+        }
+
+        Ok(())
+    }
+
+    fn validate_welcome(
+        &self,
+        welcome: &Welcome,
+        version: ProtocolVersion,
+    ) -> Result<(), MlsError> {
+        let state = self.group_state();
+
+        (welcome.cipher_suite == state.context.cipher_suite
+            && version == state.context.protocol_version)
+            .then_some(())
+            .ok_or(MlsError::InvalidWelcomeMessage)
+    }
+
+    async fn validate_key_package(
+        &self,
+        key_package: &KeyPackage,
+        version: ProtocolVersion,
+    ) -> Result<(), MlsError> {
+        let cs = self.cipher_suite_provider();
+        let id = self.identity_provider();
+
+        validate_key_package(key_package, version, cs, &id).await
+    }
+
+    #[cfg(feature = "private_message")]
+    async fn process_ciphertext(
+        &mut self,
+        cipher_text: &PrivateMessage,
+    ) -> Result<EventOrContent<Self::OutputType>, MlsError>;
+
+    async fn verify_plaintext_authentication(
+        &self,
+        message: PublicMessage,
+    ) -> Result<EventOrContent<Self::OutputType>, MlsError>;
+
+    async fn apply_update_path(
+        &mut self,
+        sender: LeafIndex,
+        update_path: &ValidatedUpdatePath,
+        provisional_state: &mut ProvisionalState,
+    ) -> Result<Option<(TreeKemPrivate, PathSecret)>, MlsError> {
+        provisional_state
+            .public_tree
+            .apply_update_path(
+                sender,
+                update_path,
+                &provisional_state.group_context.extensions,
+                self.identity_provider(),
+                self.cipher_suite_provider(),
+            )
+            .await
+            .map(|_| None)
+    }
+
+    async fn update_key_schedule(
+        &mut self,
+        secrets: Option<(TreeKemPrivate, PathSecret)>,
+        interim_transcript_hash: InterimTranscriptHash,
+        confirmation_tag: &ConfirmationTag,
+        provisional_public_state: ProvisionalState,
+    ) -> Result<(), MlsError>;
+}
+
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+pub(crate) async fn validate_key_package<C: CipherSuiteProvider, I: IdentityProvider>(
+    key_package: &KeyPackage,
+    version: ProtocolVersion,
+    cs: &C,
+    id: &I,
+) -> Result<(), MlsError> {
+    let validator = LeafNodeValidator::new(cs, id, None);
+
+    #[cfg(feature = "std")]
+    let context = Some(MlsTime::now());
+
+    #[cfg(not(feature = "std"))]
+    let context = None;
+
+    let context = ValidationContext::Add(context);
+
+    validator
+        .check_if_valid(&key_package.leaf_node, context)
+        .await?;
+
+    validate_key_package_properties(key_package, version, cs).await?;
+
+    Ok(())
+}
diff --git a/src/group/message_signature.rs b/src/group/message_signature.rs
new file mode 100644
index 0000000..3c08935
--- /dev/null
+++ b/src/group/message_signature.rs
@@ -0,0 +1,274 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// Copyright by contributors to this project.
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+
+use super::framing::Content;
+use crate::client::MlsError;
+use crate::crypto::SignatureSecretKey;
+use crate::group::framing::{ContentType, FramedContent, PublicMessage, Sender, WireFormat};
+use crate::group::{ConfirmationTag, GroupContext};
+use crate::signer::Signable;
+use crate::CipherSuiteProvider;
+use alloc::vec;
+use alloc::vec::Vec;
+use core::{
+    fmt::{self, Debug},
+    ops::Deref,
+};
+use mls_rs_codec::{MlsDecode, MlsEncode, MlsSize};
+use mls_rs_core::protocol_version::ProtocolVersion;
+
+#[derive(Clone, Debug, PartialEq)]
+#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+pub struct FramedContentAuthData {
+    pub signature: MessageSignature,
+    pub confirmation_tag: Option<ConfirmationTag>,
+}
+
+impl MlsSize for FramedContentAuthData {
+    fn mls_encoded_len(&self) -> usize {
+        self.signature.mls_encoded_len()
+            + self
+                .confirmation_tag
+                .as_ref()
+                .map_or(0, |tag| tag.mls_encoded_len())
+    }
+}
+
+impl MlsEncode for FramedContentAuthData {
+    fn mls_encode(&self, writer: &mut Vec<u8>) -> Result<(), mls_rs_codec::Error> {
+        self.signature.mls_encode(writer)?;
+
+        if let Some(ref tag) = self.confirmation_tag {
+            tag.mls_encode(writer)?;
+        }
+
+        Ok(())
+    }
+}
+
+impl FramedContentAuthData {
+    pub(crate) fn mls_decode(
+        reader: &mut &[u8],
+        content_type: ContentType,
+    ) -> Result<Self, mls_rs_codec::Error> {
+        Ok(FramedContentAuthData {
+            signature: MessageSignature::mls_decode(reader)?,
+            confirmation_tag: match content_type {
+                ContentType::Commit => Some(ConfirmationTag::mls_decode(reader)?),
+                #[cfg(feature = "private_message")]
+                ContentType::Application => None,
+                #[cfg(feature = "by_ref_proposal")]
+                ContentType::Proposal => None,
+            },
+        })
+    }
+}
+
+#[derive(Clone, Debug, PartialEq, MlsSize, MlsEncode)]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+pub struct AuthenticatedContent {
+    pub(crate) wire_format: WireFormat,
+    pub(crate) content: FramedContent,
+    pub(crate) auth: FramedContentAuthData,
+}
+
+impl From<PublicMessage> for AuthenticatedContent {
+    fn from(p: PublicMessage) -> Self {
+        Self {
+            wire_format: WireFormat::PublicMessage,
+            content: p.content,
+            auth: p.auth,
+        }
+    }
+}
+
+impl AuthenticatedContent {
+    pub(crate) fn new(
+        context: &GroupContext,
+        sender: Sender,
+        content: Content,
+        authenticated_data: Vec<u8>,
+        wire_format: WireFormat,
+    ) -> AuthenticatedContent {
+        AuthenticatedContent {
+            wire_format,
+            content: FramedContent {
+                group_id: context.group_id.clone(),
+                epoch: context.epoch,
+                sender,
+                authenticated_data,
+                content,
+            },
+            auth: FramedContentAuthData {
+                signature: MessageSignature::empty(),
+                confirmation_tag: None,
+            },
+        }
+    }
+
+    #[inline(never)]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(crate) async fn new_signed<P: CipherSuiteProvider>(
+        signature_provider: &P,
+        context: &GroupContext,
+        sender: Sender,
+        content: Content,
+        signer: &SignatureSecretKey,
+        wire_format: WireFormat,
+        authenticated_data: Vec<u8>,
+    ) -> Result<AuthenticatedContent, MlsError> {
+        // Construct an MlsPlaintext object containing the content
+        let mut plaintext =
+            AuthenticatedContent::new(context, sender, content, authenticated_data, wire_format);
+
+        let signing_context = MessageSigningContext {
+            group_context: Some(context),
+            protocol_version: context.protocol_version,
+        };
+
+        // Sign the MlsPlaintext using the current epoch's GroupContext as context.
+        plaintext
+            .sign(signature_provider, signer, &signing_context)
+            .await?;
+
+        Ok(plaintext)
+    }
+}
+
+impl MlsDecode for AuthenticatedContent {
+    fn mls_decode(reader: &mut &[u8]) -> Result<Self, mls_rs_codec::Error> {
+        let wire_format = WireFormat::mls_decode(reader)?;
+        let content = FramedContent::mls_decode(reader)?;
+        let auth_data = FramedContentAuthData::mls_decode(reader, content.content_type())?;
+
+        Ok(AuthenticatedContent {
+            wire_format,
+            content,
+            auth: auth_data,
+        })
+    }
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub(crate) struct AuthenticatedContentTBS<'a> {
+    pub(crate) protocol_version: ProtocolVersion,
+    pub(crate) wire_format: WireFormat,
+    pub(crate) content: &'a FramedContent,
+    pub(crate) context: Option<&'a GroupContext>,
+}
+
+impl<'a> MlsSize for AuthenticatedContentTBS<'a> {
+    fn mls_encoded_len(&self) -> usize {
+        self.protocol_version.mls_encoded_len()
+            + self.wire_format.mls_encoded_len()
+            + self.content.mls_encoded_len()
+            + self.context.as_ref().map_or(0, |ctx| ctx.mls_encoded_len())
+    }
+}
+
+impl<'a> MlsEncode for AuthenticatedContentTBS<'a> {
+    fn mls_encode(&self, writer: &mut Vec<u8>) -> Result<(), mls_rs_codec::Error> {
+        self.protocol_version.mls_encode(writer)?;
+        self.wire_format.mls_encode(writer)?;
+        self.content.mls_encode(writer)?;
+
+        if let Some(context) = self.context {
+            context.mls_encode(writer)?;
+        }
+
+        Ok(())
+    }
+}
+
+impl<'a> AuthenticatedContentTBS<'a> {
+    /// The group context must not be `None` when the sender is `Member` or `NewMember`.
+    pub(crate) fn from_authenticated_content(
+        auth_content: &'a AuthenticatedContent,
+        group_context: Option<&'a GroupContext>,
+        protocol_version: ProtocolVersion,
+    ) -> Self {
+        AuthenticatedContentTBS {
+            protocol_version,
+            wire_format: auth_content.wire_format,
+            content: &auth_content.content,
+            context: match auth_content.content.sender {
+                Sender::Member(_) | Sender::NewMemberCommit => group_context,
+                #[cfg(feature = "by_ref_proposal")]
+                Sender::External(_) => None,
+                #[cfg(feature = "by_ref_proposal")]
+                Sender::NewMemberProposal => None,
+            },
+        }
+    }
+}
+
+#[derive(Debug)]
+pub(crate) struct MessageSigningContext<'a> {
+    pub group_context: Option<&'a GroupContext>,
+    pub protocol_version: ProtocolVersion,
+}
+
+impl<'a> Signable<'a> for AuthenticatedContent {
+    const SIGN_LABEL: &'static str = "FramedContentTBS";
+
+    type SigningContext = MessageSigningContext<'a>;
+
+    fn signature(&self) -> &[u8] {
+        &self.auth.signature
+    }
+
+    fn signable_content(
+        &self,
+        context: &MessageSigningContext,
+    ) -> Result<Vec<u8>, mls_rs_codec::Error> {
+        AuthenticatedContentTBS::from_authenticated_content(
+            self,
+            context.group_context,
+            context.protocol_version,
+        )
+        .mls_encode_to_vec()
+    }
+
+    fn write_signature(&mut self, signature: Vec<u8>) {
+        self.auth.signature = MessageSignature::from(signature)
+    }
+}
+
+#[derive(Clone, PartialEq, Eq, MlsSize, MlsEncode, MlsDecode)]
+#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+pub struct MessageSignature(
+    #[mls_codec(with = "mls_rs_codec::byte_vec")]
+    #[cfg_attr(feature = "serde", serde(with = "mls_rs_core::vec_serde"))]
+    Vec<u8>,
+);
+
+impl Debug for MessageSignature {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        mls_rs_core::debug::pretty_bytes(&self.0)
+            .named("MessageSignature")
+            .fmt(f)
+    }
+}
+
+impl MessageSignature {
+    pub(crate) fn empty() -> Self {
+        MessageSignature(vec![])
+    }
+}
+
+impl Deref for MessageSignature {
+    type Target = Vec<u8>;
+
+    fn deref(&self) -> &Self::Target {
+        &self.0
+    }
+}
+
+impl From<Vec<u8>> for MessageSignature {
+    fn from(v: Vec<u8>) -> Self {
+        MessageSignature(v)
+    }
+}
diff --git a/src/group/message_verifier.rs b/src/group/message_verifier.rs
new file mode 100644
index 0000000..7a2bc59
--- /dev/null
+++ b/src/group/message_verifier.rs
@@ -0,0 +1,680 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// Copyright by contributors to this project.
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+
+#[cfg(feature = "by_ref_proposal")]
+use alloc::{vec, vec::Vec};
+
+use crate::{
+    client::MlsError,
+    crypto::SignaturePublicKey,
+    group::{GroupContext, PublicMessage, Sender},
+    signer::Signable,
+    tree_kem::{node::LeafIndex, TreeKemPublic},
+    CipherSuiteProvider,
+};
+
+#[cfg(feature = "by_ref_proposal")]
+use crate::{extension::ExternalSendersExt, identity::SigningIdentity};
+
+use super::{
+    key_schedule::KeySchedule,
+    message_signature::{AuthenticatedContent, MessageSigningContext},
+    state::GroupState,
+};
+
+#[cfg(feature = "by_ref_proposal")]
+use super::proposal::Proposal;
+
+#[derive(Debug)]
+pub(crate) enum SignaturePublicKeysContainer<'a> {
+    RatchetTree(&'a TreeKemPublic),
+    #[cfg(feature = "private_message")]
+    List(&'a [Option<SignaturePublicKey>]),
+}
+
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+pub(crate) async fn verify_plaintext_authentication<P: CipherSuiteProvider>(
+    cipher_suite_provider: &P,
+    plaintext: PublicMessage,
+    key_schedule: Option<&KeySchedule>,
+    self_index: Option<LeafIndex>,
+    state: &GroupState,
+) -> 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(index) => {
+            if let Some(key_schedule) = key_schedule {
+                let expected_tag = &key_schedule
+                    .get_membership_tag(&auth_content, context, cipher_suite_provider)
+                    .await?;
+
+                let plaintext_tag = tag.as_ref().ok_or(MlsError::InvalidMembershipTag)?;
+
+                if expected_tag != plaintext_tag {
+                    return Err(MlsError::InvalidMembershipTag);
+                }
+            }
+
+            if self_index == Some(LeafIndex(*index)) {
+                return Err(MlsError::CantProcessMessageFromSelf);
+            }
+        }
+        _ => {
+            tag.is_none()
+                .then_some(())
+                .ok_or(MlsError::MembershipTagForNonMember)?;
+        }
+    }
+
+    // Verify that the signature on the MLSAuthenticatedContent verifies using the public key
+    // 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),
+        context,
+        &auth_content,
+        #[cfg(feature = "by_ref_proposal")]
+        &external_signers,
+    )
+    .await?;
+
+    Ok(auth_content)
+}
+
+#[cfg(feature = "by_ref_proposal")]
+fn external_signers(context: &GroupContext) -> Vec<SigningIdentity> {
+    context
+        .extensions
+        .get_as::<ExternalSendersExt>()
+        .unwrap_or(None)
+        .map_or(vec![], |extern_senders_ext| {
+            extern_senders_ext.allowed_senders
+        })
+}
+
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+pub(crate) async fn verify_auth_content_signature<P: CipherSuiteProvider>(
+    cipher_suite_provider: &P,
+    signature_keys_container: SignaturePublicKeysContainer<'_>,
+    context: &GroupContext,
+    auth_content: &AuthenticatedContent,
+    #[cfg(feature = "by_ref_proposal")] external_signers: &[SigningIdentity],
+) -> Result<(), MlsError> {
+    let sender_public_key = signing_identity_for_sender(
+        signature_keys_container,
+        &auth_content.content.sender,
+        &auth_content.content.content,
+        #[cfg(feature = "by_ref_proposal")]
+        external_signers,
+    )?;
+
+    let context = MessageSigningContext {
+        group_context: Some(context),
+        protocol_version: context.protocol_version,
+    };
+
+    auth_content
+        .verify(cipher_suite_provider, &sender_public_key, &context)
+        .await?;
+
+    Ok(())
+}
+
+fn signing_identity_for_sender(
+    signature_keys_container: SignaturePublicKeysContainer,
+    sender: &Sender,
+    content: &super::framing::Content,
+    #[cfg(feature = "by_ref_proposal")] external_signers: &[SigningIdentity],
+) -> Result<SignaturePublicKey, MlsError> {
+    match sender {
+        Sender::Member(leaf_index) => {
+            signing_identity_for_member(signature_keys_container, LeafIndex(*leaf_index))
+        }
+        #[cfg(feature = "by_ref_proposal")]
+        Sender::External(external_key_index) => {
+            signing_identity_for_external(*external_key_index, external_signers)
+        }
+        Sender::NewMemberCommit => signing_identity_for_new_member_commit(content),
+        #[cfg(feature = "by_ref_proposal")]
+        Sender::NewMemberProposal => signing_identity_for_new_member_proposal(content),
+    }
+}
+
+fn signing_identity_for_member(
+    signature_keys_container: SignaturePublicKeysContainer,
+    leaf_index: LeafIndex,
+) -> Result<SignaturePublicKey, MlsError> {
+    match signature_keys_container {
+        SignaturePublicKeysContainer::RatchetTree(tree) => Ok(tree
+            .get_leaf_node(leaf_index)?
+            .signing_identity
+            .signature_key
+            .clone()), // TODO: We can probably get rid of this clone
+        #[cfg(feature = "private_message")]
+        SignaturePublicKeysContainer::List(list) => list
+            .get(leaf_index.0 as usize)
+            .cloned()
+            .flatten()
+            .ok_or(MlsError::LeafNotFound(*leaf_index)),
+    }
+}
+
+#[cfg(feature = "by_ref_proposal")]
+fn signing_identity_for_external(
+    index: u32,
+    external_signers: &[SigningIdentity],
+) -> Result<SignaturePublicKey, MlsError> {
+    external_signers
+        .get(index as usize)
+        .map(|spk| spk.signature_key.clone())
+        .ok_or(MlsError::UnknownSigningIdentityForExternalSender)
+}
+
+fn signing_identity_for_new_member_commit(
+    content: &super::framing::Content,
+) -> Result<SignaturePublicKey, MlsError> {
+    match content {
+        super::framing::Content::Commit(commit) => {
+            if let Some(path) = &commit.path {
+                Ok(path.leaf_node.signing_identity.signature_key.clone())
+            } else {
+                Err(MlsError::CommitMissingPath)
+            }
+        }
+        #[cfg(any(feature = "private_message", feature = "by_ref_proposal"))]
+        _ => Err(MlsError::ExpectedCommitForNewMemberCommit),
+    }
+}
+
+#[cfg(feature = "by_ref_proposal")]
+fn signing_identity_for_new_member_proposal(
+    content: &super::framing::Content,
+) -> Result<SignaturePublicKey, MlsError> {
+    match content {
+        super::framing::Content::Proposal(proposal) => {
+            if let Proposal::Add(p) = proposal.as_ref() {
+                Ok(p.key_package
+                    .leaf_node
+                    .signing_identity
+                    .signature_key
+                    .clone())
+            } else {
+                Err(MlsError::ExpectedAddProposalForNewMemberProposal)
+            }
+        }
+        _ => Err(MlsError::ExpectedAddProposalForNewMemberProposal),
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use crate::{
+        client::{
+            test_utils::{test_client_with_key_pkg, TEST_CIPHER_SUITE, TEST_PROTOCOL_VERSION},
+            MlsError,
+        },
+        client_builder::test_utils::TestClientConfig,
+        crypto::test_utils::test_cipher_suite_provider,
+        group::{
+            membership_tag::MembershipTag,
+            message_signature::{AuthenticatedContent, MessageSignature},
+            test_utils::{test_group_custom, TestGroup},
+            Group, PublicMessage,
+        },
+        tree_kem::node::LeafIndex,
+    };
+    use alloc::vec;
+    use assert_matches::assert_matches;
+
+    #[cfg(feature = "by_ref_proposal")]
+    use crate::{extension::ExternalSendersExt, ExtensionList};
+
+    #[cfg(feature = "by_ref_proposal")]
+    use crate::{
+        crypto::SignatureSecretKey,
+        group::{
+            message_signature::MessageSigningContext,
+            proposal::{AddProposal, Proposal, RemoveProposal},
+            Content,
+        },
+        key_package::KeyPackageGeneration,
+        signer::Signable,
+        WireFormat,
+    };
+
+    #[cfg(feature = "by_ref_proposal")]
+    use alloc::boxed::Box;
+
+    use crate::group::{
+        test_utils::{test_group, test_member},
+        Sender,
+    };
+
+    #[cfg(feature = "by_ref_proposal")]
+    use crate::identity::test_utils::get_test_signing_identity;
+
+    use super::{verify_auth_content_signature, verify_plaintext_authentication};
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn make_signed_plaintext(group: &mut Group<TestClientConfig>) -> PublicMessage {
+        group
+            .commit(vec![])
+            .await
+            .unwrap()
+            .commit_message
+            .into_plaintext()
+            .unwrap()
+    }
+
+    struct TestEnv {
+        alice: TestGroup,
+        bob: TestGroup,
+    }
+
+    impl TestEnv {
+        #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+        async fn new() -> Self {
+            let mut alice = test_group_custom(
+                TEST_PROTOCOL_VERSION,
+                TEST_CIPHER_SUITE,
+                Default::default(),
+                None,
+                None,
+            )
+            .await;
+
+            let (bob_client, bob_key_pkg) =
+                test_client_with_key_pkg(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, "bob").await;
+
+            let commit_output = alice
+                .group
+                .commit_builder()
+                .add_member(bob_key_pkg)
+                .unwrap()
+                .build()
+                .await
+                .unwrap();
+
+            alice.group.apply_pending_commit().await.unwrap();
+
+            let (bob, _) = Group::join(
+                &commit_output.welcome_messages[0],
+                None,
+                bob_client.config,
+                bob_client.signer.unwrap(),
+            )
+            .await
+            .unwrap();
+
+            TestEnv {
+                alice,
+                bob: TestGroup { group: bob },
+            }
+        }
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn valid_plaintext_is_verified() {
+        let mut env = TestEnv::new().await;
+
+        let message = make_signed_plaintext(&mut env.alice.group).await;
+
+        verify_plaintext_authentication(
+            &env.bob.group.cipher_suite_provider,
+            message,
+            Some(&env.bob.group.key_schedule),
+            None,
+            &env.bob.group.state,
+        )
+        .await
+        .unwrap();
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn valid_auth_content_is_verified() {
+        let mut env = TestEnv::new().await;
+
+        let message = AuthenticatedContent::from(make_signed_plaintext(&mut env.alice.group).await);
+
+        verify_auth_content_signature(
+            &env.bob.group.cipher_suite_provider,
+            super::SignaturePublicKeysContainer::RatchetTree(&env.bob.group.state.public_tree),
+            env.bob.group.context(),
+            &message,
+            #[cfg(feature = "by_ref_proposal")]
+            &[],
+        )
+        .await
+        .unwrap();
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn invalid_plaintext_is_not_verified() {
+        let mut env = TestEnv::new().await;
+        let mut message = make_signed_plaintext(&mut env.alice.group).await;
+        message.auth.signature = MessageSignature::from(b"test".to_vec());
+
+        message.membership_tag = env
+            .alice
+            .group
+            .key_schedule
+            .get_membership_tag(
+                &AuthenticatedContent::from(message.clone()),
+                env.alice.group.context(),
+                &test_cipher_suite_provider(env.alice.group.cipher_suite()),
+            )
+            .await
+            .unwrap()
+            .into();
+
+        let res = verify_plaintext_authentication(
+            &env.bob.group.cipher_suite_provider,
+            message,
+            Some(&env.bob.group.key_schedule),
+            None,
+            &env.bob.group.state,
+        )
+        .await;
+
+        assert_matches!(res, Err(MlsError::InvalidSignature));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn plaintext_from_member_requires_membership_tag() {
+        let mut env = TestEnv::new().await;
+        let mut message = make_signed_plaintext(&mut env.alice.group).await;
+        message.membership_tag = None;
+
+        let res = verify_plaintext_authentication(
+            &env.bob.group.cipher_suite_provider,
+            message,
+            Some(&env.bob.group.key_schedule),
+            None,
+            &env.bob.group.state,
+        )
+        .await;
+
+        assert_matches!(res, Err(MlsError::InvalidMembershipTag));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn plaintext_fails_with_invalid_membership_tag() {
+        let mut env = TestEnv::new().await;
+        let mut message = make_signed_plaintext(&mut env.alice.group).await;
+        message.membership_tag = Some(MembershipTag::from(b"test".to_vec()));
+
+        let res = verify_plaintext_authentication(
+            &env.bob.group.cipher_suite_provider,
+            message,
+            Some(&env.bob.group.key_schedule),
+            None,
+            &env.bob.group.state,
+        )
+        .await;
+
+        assert_matches!(res, Err(MlsError::InvalidMembershipTag));
+    }
+
+    #[cfg(feature = "by_ref_proposal")]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn test_new_member_proposal<F>(
+        key_pkg_gen: KeyPackageGeneration,
+        signer: &SignatureSecretKey,
+        test_group: &TestGroup,
+        mut edit: F,
+    ) -> PublicMessage
+    where
+        F: FnMut(&mut AuthenticatedContent),
+    {
+        let mut content = AuthenticatedContent::new_signed(
+            &test_group.group.cipher_suite_provider,
+            test_group.group.context(),
+            Sender::NewMemberProposal,
+            Content::Proposal(Box::new(Proposal::Add(Box::new(AddProposal {
+                key_package: key_pkg_gen.key_package,
+            })))),
+            signer,
+            WireFormat::PublicMessage,
+            vec![],
+        )
+        .await
+        .unwrap();
+
+        edit(&mut content);
+
+        let signing_context = MessageSigningContext {
+            group_context: Some(test_group.group.context()),
+            protocol_version: test_group.group.protocol_version(),
+        };
+
+        content
+            .sign(
+                &test_group.group.cipher_suite_provider,
+                signer,
+                &signing_context,
+            )
+            .await
+            .unwrap();
+
+        PublicMessage {
+            content: content.content,
+            auth: content.auth,
+            membership_tag: None,
+        }
+    }
+
+    #[cfg(feature = "by_ref_proposal")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn valid_proposal_from_new_member_is_verified() {
+        let test_group = test_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await;
+        let (key_pkg_gen, signer) =
+            test_member(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, b"bob").await;
+        let message = test_new_member_proposal(key_pkg_gen, &signer, &test_group, |_| {}).await;
+
+        verify_plaintext_authentication(
+            &test_group.group.cipher_suite_provider,
+            message,
+            Some(&test_group.group.key_schedule),
+            None,
+            &test_group.group.state,
+        )
+        .await
+        .unwrap();
+    }
+
+    #[cfg(feature = "by_ref_proposal")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn proposal_from_new_member_must_not_have_membership_tag() {
+        let test_group = test_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await;
+        let (key_pkg_gen, signer) =
+            test_member(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, b"bob").await;
+
+        let mut message = test_new_member_proposal(key_pkg_gen, &signer, &test_group, |_| {}).await;
+        message.membership_tag = Some(MembershipTag::from(vec![]));
+
+        let res = verify_plaintext_authentication(
+            &test_group.group.cipher_suite_provider,
+            message,
+            Some(&test_group.group.key_schedule),
+            None,
+            &test_group.group.state,
+        )
+        .await;
+
+        assert_matches!(res, Err(MlsError::MembershipTagForNonMember));
+    }
+
+    #[cfg(feature = "by_ref_proposal")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn new_member_proposal_sender_must_be_add_proposal() {
+        let test_group = test_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await;
+        let (key_pkg_gen, signer) =
+            test_member(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, b"bob").await;
+
+        let message = test_new_member_proposal(key_pkg_gen, &signer, &test_group, |msg| {
+            msg.content.content = Content::Proposal(Box::new(Proposal::Remove(RemoveProposal {
+                to_remove: LeafIndex(0),
+            })))
+        })
+        .await;
+
+        let res: Result<AuthenticatedContent, MlsError> = verify_plaintext_authentication(
+            &test_group.group.cipher_suite_provider,
+            message,
+            Some(&test_group.group.key_schedule),
+            None,
+            &test_group.group.state,
+        )
+        .await;
+
+        assert_matches!(res, Err(MlsError::ExpectedAddProposalForNewMemberProposal));
+    }
+
+    #[cfg(feature = "by_ref_proposal")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn new_member_commit_must_be_external_commit() {
+        let test_group = test_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await;
+        let (key_pkg_gen, signer) =
+            test_member(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, b"bob").await;
+
+        let message = test_new_member_proposal(key_pkg_gen, &signer, &test_group, |msg| {
+            msg.content.sender = Sender::NewMemberCommit;
+        })
+        .await;
+
+        let res = verify_plaintext_authentication(
+            &test_group.group.cipher_suite_provider,
+            message,
+            Some(&test_group.group.key_schedule),
+            None,
+            &test_group.group.state,
+        )
+        .await;
+
+        assert_matches!(res, Err(MlsError::ExpectedCommitForNewMemberCommit));
+    }
+
+    #[cfg(feature = "by_ref_proposal")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn valid_proposal_from_external_is_verified() {
+        let (bob_key_pkg_gen, _) =
+            test_member(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, b"bob").await;
+
+        let (ted_signing, ted_secret) = get_test_signing_identity(TEST_CIPHER_SUITE, b"ted").await;
+
+        let mut test_group = test_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await;
+        let mut extensions = ExtensionList::default();
+
+        extensions
+            .set_from(ExternalSendersExt {
+                allowed_senders: vec![ted_signing],
+            })
+            .unwrap();
+
+        test_group
+            .group
+            .commit_builder()
+            .set_group_context_ext(extensions)
+            .unwrap()
+            .build()
+            .await
+            .unwrap();
+
+        test_group.group.apply_pending_commit().await.unwrap();
+
+        let message = test_new_member_proposal(bob_key_pkg_gen, &ted_secret, &test_group, |msg| {
+            msg.content.sender = Sender::External(0)
+        })
+        .await;
+
+        verify_plaintext_authentication(
+            &test_group.group.cipher_suite_provider,
+            message,
+            Some(&test_group.group.key_schedule),
+            None,
+            &test_group.group.state,
+        )
+        .await
+        .unwrap();
+    }
+
+    #[cfg(feature = "by_ref_proposal")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn external_proposal_must_be_from_valid_sender() {
+        let (bob_key_pkg_gen, _) =
+            test_member(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, b"bob").await;
+        let (_, ted_secret) = get_test_signing_identity(TEST_CIPHER_SUITE, b"ted").await;
+        let test_group = test_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await;
+
+        let message = test_new_member_proposal(bob_key_pkg_gen, &ted_secret, &test_group, |msg| {
+            msg.content.sender = Sender::External(0)
+        })
+        .await;
+
+        let res = verify_plaintext_authentication(
+            &test_group.group.cipher_suite_provider,
+            message,
+            Some(&test_group.group.key_schedule),
+            None,
+            &test_group.group.state,
+        )
+        .await;
+
+        assert_matches!(res, Err(MlsError::UnknownSigningIdentityForExternalSender));
+    }
+
+    #[cfg(feature = "by_ref_proposal")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn proposal_from_external_sender_must_not_have_membership_tag() {
+        let (bob_key_pkg_gen, _) =
+            test_member(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, b"bob").await;
+
+        let (_, ted_secret) = get_test_signing_identity(TEST_CIPHER_SUITE, b"ted").await;
+
+        let test_group = test_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await;
+
+        let mut message =
+            test_new_member_proposal(bob_key_pkg_gen, &ted_secret, &test_group, |_| {}).await;
+
+        message.membership_tag = Some(MembershipTag::from(vec![]));
+
+        let res = verify_plaintext_authentication(
+            &test_group.group.cipher_suite_provider,
+            message,
+            Some(&test_group.group.key_schedule),
+            None,
+            &test_group.group.state,
+        )
+        .await;
+
+        assert_matches!(res, Err(MlsError::MembershipTagForNonMember));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn plaintext_from_self_fails_verification() {
+        let mut env = TestEnv::new().await;
+
+        let message = make_signed_plaintext(&mut env.alice.group).await;
+
+        let res = verify_plaintext_authentication(
+            &env.alice.group.cipher_suite_provider,
+            message,
+            Some(&env.alice.group.key_schedule),
+            Some(LeafIndex::new(env.alice.group.current_member_index())),
+            &env.alice.group.state,
+        )
+        .await;
+
+        assert_matches!(res, Err(MlsError::CantProcessMessageFromSelf))
+    }
+}
diff --git a/src/group/mls_rules.rs b/src/group/mls_rules.rs
new file mode 100644
index 0000000..98b1dac
--- /dev/null
+++ b/src/group/mls_rules.rs
@@ -0,0 +1,283 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// Copyright by contributors to this project.
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+
+use crate::group::{proposal_filter::ProposalBundle, Roster};
+
+#[cfg(feature = "private_message")]
+use crate::{
+    group::{padding::PaddingMode, Sender},
+    WireFormat,
+};
+
+use alloc::boxed::Box;
+use core::convert::Infallible;
+use mls_rs_core::{
+    error::IntoAnyError, extension::ExtensionList, group::Member, identity::SigningIdentity,
+};
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq)]
+pub enum CommitDirection {
+    Send,
+    Receive,
+}
+
+/// The source of the commit: either a current member or a new member joining
+/// via external commit.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum CommitSource {
+    ExistingMember(Member),
+    NewMember(SigningIdentity),
+}
+
+/// Options controlling commit generation
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+#[non_exhaustive]
+pub struct CommitOptions {
+    pub path_required: bool,
+    pub ratchet_tree_extension: bool,
+    pub single_welcome_message: bool,
+    pub allow_external_commit: bool,
+}
+
+impl Default for CommitOptions {
+    fn default() -> Self {
+        CommitOptions {
+            path_required: false,
+            ratchet_tree_extension: true,
+            single_welcome_message: true,
+            allow_external_commit: false,
+        }
+    }
+}
+
+impl CommitOptions {
+    pub fn new() -> Self {
+        Self::default()
+    }
+
+    pub fn with_path_required(self, path_required: bool) -> Self {
+        Self {
+            path_required,
+            ..self
+        }
+    }
+
+    pub fn with_ratchet_tree_extension(self, ratchet_tree_extension: bool) -> Self {
+        Self {
+            ratchet_tree_extension,
+            ..self
+        }
+    }
+
+    pub fn with_single_welcome_message(self, single_welcome_message: bool) -> Self {
+        Self {
+            single_welcome_message,
+            ..self
+        }
+    }
+
+    pub fn with_allow_external_commit(self, allow_external_commit: bool) -> Self {
+        Self {
+            allow_external_commit,
+            ..self
+        }
+    }
+}
+
+/// Options controlling encryption of control and application messages
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+#[non_exhaustive]
+pub struct EncryptionOptions {
+    #[cfg(feature = "private_message")]
+    pub encrypt_control_messages: bool,
+    #[cfg(feature = "private_message")]
+    pub padding_mode: PaddingMode,
+}
+
+#[cfg(feature = "private_message")]
+impl EncryptionOptions {
+    pub fn new(encrypt_control_messages: bool, padding_mode: PaddingMode) -> Self {
+        Self {
+            encrypt_control_messages,
+            padding_mode,
+        }
+    }
+
+    pub(crate) fn control_wire_format(&self, sender: Sender) -> WireFormat {
+        match sender {
+            Sender::Member(_) if self.encrypt_control_messages => WireFormat::PrivateMessage,
+            _ => WireFormat::PublicMessage,
+        }
+    }
+}
+
+/// A set of user controlled rules that customize the behavior of MLS.
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+#[cfg_attr(mls_build_async, maybe_async::must_be_async)]
+pub trait MlsRules: Send + Sync {
+    type Error: IntoAnyError;
+
+    /// This is called when preparing or receiving a commit to pre-process the set of committed
+    /// proposals.
+    ///
+    /// Both proposals received during the current epoch and at the time of commit
+    /// will be presented for validation and filtering. Filter and validate will
+    /// present a raw list of proposals. Standard MLS rules are applied internally
+    /// on the result of these rules.
+    ///
+    /// Each member of a group MUST apply the same proposal rules in order to
+    /// maintain a working group.
+    ///
+    /// Typically, any invalid proposal should result in an error. The exception are invalid
+    /// by-reference proposals processed when _preparing_ a commit, which should be filtered
+    /// out instead. This is to avoid the deadlock situation when no commit can be generated
+    /// after receiving an invalid set of proposal messages.
+    ///
+    /// `ProposalBundle` can be arbitrarily modified. For example, a Remove proposal that
+    /// removes a moderator can result in adding a GroupContextExtensions proposal that updates
+    /// the moderator list in the group context. The resulting `ProposalBundle` is validated
+    /// by the library.
+    async fn filter_proposals(
+        &self,
+        direction: CommitDirection,
+        source: CommitSource,
+        current_roster: &Roster,
+        extension_list: &ExtensionList,
+        proposals: ProposalBundle,
+    ) -> Result<ProposalBundle, Self::Error>;
+
+    /// This is called when preparing a commit to determine various options: whether to enforce an update
+    /// path in case it is not mandated by MLS, whether to include the ratchet tree in the welcome
+    /// message (if the commit adds members) and whether to generate a single welcome message, or one
+    /// welcome message for each added member.
+    ///
+    /// The `new_roster` and `new_extension_list` describe the group state after the commit.
+    fn commit_options(
+        &self,
+        new_roster: &Roster,
+        new_extension_list: &ExtensionList,
+        proposals: &ProposalBundle,
+    ) -> Result<CommitOptions, Self::Error>;
+
+    /// This is called when sending any packet. For proposals and commits, this determines whether to
+    /// encrypt them. For any encrypted packet, this determines the padding mode used.
+    ///
+    /// Note that for commits, the `current_roster` and `current_extension_list` describe the group state
+    /// before the commit, unlike in [commit_options](MlsRules::commit_options).
+    fn encryption_options(
+        &self,
+        current_roster: &Roster,
+        current_extension_list: &ExtensionList,
+    ) -> Result<EncryptionOptions, Self::Error>;
+}
+
+macro_rules! delegate_mls_rules {
+    ($implementer:ty) => {
+        #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+        #[cfg_attr(mls_build_async, maybe_async::must_be_async)]
+        impl<T: MlsRules + ?Sized> MlsRules for $implementer {
+            type Error = T::Error;
+
+            #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+            async fn filter_proposals(
+                &self,
+                direction: CommitDirection,
+                source: CommitSource,
+                current_roster: &Roster,
+                extension_list: &ExtensionList,
+                proposals: ProposalBundle,
+            ) -> Result<ProposalBundle, Self::Error> {
+                (**self)
+                    .filter_proposals(direction, source, current_roster, extension_list, proposals)
+                    .await
+            }
+
+            fn commit_options(
+                &self,
+                roster: &Roster,
+                extension_list: &ExtensionList,
+                proposals: &ProposalBundle,
+            ) -> Result<CommitOptions, Self::Error> {
+                (**self).commit_options(roster, extension_list, proposals)
+            }
+
+            fn encryption_options(
+                &self,
+                roster: &Roster,
+                extension_list: &ExtensionList,
+            ) -> Result<EncryptionOptions, Self::Error> {
+                (**self).encryption_options(roster, extension_list)
+            }
+        }
+    };
+}
+
+delegate_mls_rules!(Box<T>);
+delegate_mls_rules!(&T);
+
+#[derive(Clone, Debug, Default)]
+#[non_exhaustive]
+/// Default MLS rules with pass-through proposal filter and customizable options.
+pub struct DefaultMlsRules {
+    pub commit_options: CommitOptions,
+    pub encryption_options: EncryptionOptions,
+}
+
+impl DefaultMlsRules {
+    /// Create new MLS rules with default settings: do not enforce path and do
+    /// put the ratchet tree in the extension.
+    pub fn new() -> Self {
+        Default::default()
+    }
+
+    /// Set commit options.
+    pub fn with_commit_options(self, commit_options: CommitOptions) -> Self {
+        Self {
+            commit_options,
+            encryption_options: self.encryption_options,
+        }
+    }
+
+    /// Set encryption options.
+    pub fn with_encryption_options(self, encryption_options: EncryptionOptions) -> Self {
+        Self {
+            commit_options: self.commit_options,
+            encryption_options,
+        }
+    }
+}
+
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+#[cfg_attr(mls_build_async, maybe_async::must_be_async)]
+impl MlsRules for DefaultMlsRules {
+    type Error = Infallible;
+
+    async fn filter_proposals(
+        &self,
+        _direction: CommitDirection,
+        _source: CommitSource,
+        _current_roster: &Roster,
+        _extension_list: &ExtensionList,
+        proposals: ProposalBundle,
+    ) -> Result<ProposalBundle, Self::Error> {
+        Ok(proposals)
+    }
+
+    fn commit_options(
+        &self,
+        _: &Roster,
+        _: &ExtensionList,
+        _: &ProposalBundle,
+    ) -> Result<CommitOptions, Self::Error> {
+        Ok(self.commit_options)
+    }
+
+    fn encryption_options(
+        &self,
+        _: &Roster,
+        _: &ExtensionList,
+    ) -> Result<EncryptionOptions, Self::Error> {
+        Ok(self.encryption_options)
+    }
+}
diff --git a/src/group/mod.rs b/src/group/mod.rs
new file mode 100644
index 0000000..0d84a84
--- /dev/null
+++ b/src/group/mod.rs
@@ -0,0 +1,4236 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// Copyright by contributors to this project.
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+
+use alloc::vec;
+use alloc::vec::Vec;
+use core::fmt::{self, Debug};
+use mls_rs_codec::{MlsDecode, MlsEncode, MlsSize};
+use mls_rs_core::error::IntoAnyError;
+use mls_rs_core::secret::Secret;
+use mls_rs_core::time::MlsTime;
+
+use crate::cipher_suite::CipherSuite;
+use crate::client::MlsError;
+use crate::client_config::ClientConfig;
+use crate::crypto::{HpkeCiphertext, SignatureSecretKey};
+use crate::extension::RatchetTreeExt;
+use crate::identity::SigningIdentity;
+use crate::key_package::{KeyPackage, KeyPackageRef};
+use crate::protocol_version::ProtocolVersion;
+use crate::psk::secret::PskSecret;
+use crate::psk::PreSharedKeyID;
+use crate::signer::Signable;
+use crate::tree_kem::hpke_encryption::HpkeEncryptable;
+use crate::tree_kem::kem::TreeKem;
+use crate::tree_kem::node::LeafIndex;
+use crate::tree_kem::path_secret::PathSecret;
+pub use crate::tree_kem::Capabilities;
+use crate::tree_kem::{
+    leaf_node::LeafNode,
+    leaf_node_validator::{LeafNodeValidator, ValidationContext},
+};
+use crate::tree_kem::{math as tree_math, ValidatedUpdatePath};
+use crate::tree_kem::{TreeKemPrivate, TreeKemPublic};
+use crate::{CipherSuiteProvider, CryptoProvider};
+
+#[cfg(feature = "by_ref_proposal")]
+use crate::crypto::{HpkePublicKey, HpkeSecretKey};
+
+use crate::extension::ExternalPubExt;
+
+#[cfg(feature = "private_message")]
+use self::mls_rules::{EncryptionOptions, MlsRules};
+
+#[cfg(feature = "psk")]
+pub use self::resumption::ReinitClient;
+
+#[cfg(feature = "psk")]
+use crate::psk::{
+    resolver::PskResolver, secret::PskSecretInput, ExternalPskId, JustPreSharedKeyID, PskGroupId,
+    ResumptionPSKUsage, ResumptionPsk,
+};
+
+#[cfg(all(feature = "std", feature = "by_ref_proposal"))]
+use std::collections::HashMap;
+
+#[cfg(feature = "private_message")]
+use ciphertext_processor::*;
+
+use confirmation_tag::*;
+use framing::*;
+use key_schedule::*;
+use membership_tag::*;
+use message_signature::*;
+use message_verifier::*;
+use proposal::*;
+#[cfg(feature = "by_ref_proposal")]
+use proposal_cache::*;
+use state::*;
+use transcript_hash::*;
+
+#[cfg(test)]
+pub(crate) use self::commit::test_utils::CommitModifiers;
+
+#[cfg(all(test, feature = "private_message"))]
+pub use self::framing::PrivateMessage;
+
+#[cfg(feature = "psk")]
+use self::proposal_filter::ProposalInfo;
+
+#[cfg(any(feature = "secret_tree_access", feature = "private_message"))]
+use secret_tree::*;
+
+#[cfg(feature = "prior_epoch")]
+use self::epoch::PriorEpoch;
+
+use self::epoch::EpochSecrets;
+pub use self::message_processor::{
+    ApplicationMessageDescription, CommitMessageDescription, ProposalMessageDescription,
+    ProposalSender, ReceivedMessage, StateUpdate,
+};
+use self::message_processor::{EventOrContent, MessageProcessor, ProvisionalState};
+#[cfg(feature = "by_ref_proposal")]
+use self::proposal_ref::ProposalRef;
+use self::state_repo::GroupStateRepository;
+pub use group_info::GroupInfo;
+
+pub use self::framing::{ContentType, Sender};
+pub use commit::*;
+pub use context::GroupContext;
+pub use roster::*;
+
+pub(crate) use transcript_hash::ConfirmedTranscriptHash;
+pub(crate) use util::*;
+
+#[cfg(all(feature = "by_ref_proposal", feature = "external_client"))]
+pub use self::message_processor::CachedProposal;
+
+#[cfg(feature = "private_message")]
+mod ciphertext_processor;
+
+mod commit;
+pub(crate) mod confirmation_tag;
+mod context;
+pub(crate) mod epoch;
+pub(crate) mod framing;
+mod group_info;
+pub(crate) mod key_schedule;
+mod membership_tag;
+pub(crate) mod message_processor;
+pub(crate) mod message_signature;
+pub(crate) mod message_verifier;
+pub mod mls_rules;
+#[cfg(feature = "private_message")]
+pub(crate) mod padding;
+/// Proposals to evolve a MLS [`Group`]
+pub mod proposal;
+mod proposal_cache;
+pub(crate) mod proposal_filter;
+#[cfg(feature = "by_ref_proposal")]
+pub(crate) mod proposal_ref;
+#[cfg(feature = "psk")]
+mod resumption;
+mod roster;
+pub(crate) mod snapshot;
+pub(crate) mod state;
+
+#[cfg(feature = "prior_epoch")]
+pub(crate) mod state_repo;
+#[cfg(not(feature = "prior_epoch"))]
+pub(crate) mod state_repo_light;
+#[cfg(not(feature = "prior_epoch"))]
+pub(crate) use state_repo_light as state_repo;
+
+pub(crate) mod transcript_hash;
+mod util;
+
+/// External commit building.
+pub mod external_commit;
+
+#[cfg(any(feature = "secret_tree_access", feature = "private_message"))]
+pub(crate) mod secret_tree;
+
+#[cfg(any(feature = "secret_tree_access", feature = "private_message"))]
+pub use secret_tree::MessageKeyData as MessageKey;
+
+#[cfg(all(test, feature = "rfc_compliant"))]
+mod interop_test_vectors;
+
+mod exported_tree;
+
+pub use exported_tree::ExportedTree;
+
+#[derive(Clone, Debug, PartialEq, MlsSize, MlsEncode, MlsDecode)]
+struct GroupSecrets {
+    joiner_secret: JoinerSecret,
+    path_secret: Option<PathSecret>,
+    psks: Vec<PreSharedKeyID>,
+}
+
+impl HpkeEncryptable for GroupSecrets {
+    const ENCRYPT_LABEL: &'static str = "Welcome";
+
+    fn from_bytes(bytes: Vec<u8>) -> Result<Self, MlsError> {
+        Self::mls_decode(&mut bytes.as_slice()).map_err(Into::into)
+    }
+
+    fn get_bytes(&self) -> Result<Vec<u8>, MlsError> {
+        self.mls_encode_to_vec().map_err(Into::into)
+    }
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, MlsSize, MlsEncode, MlsDecode)]
+#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
+pub(crate) struct EncryptedGroupSecrets {
+    pub new_member: KeyPackageRef,
+    pub encrypted_group_secrets: HpkeCiphertext,
+}
+
+#[derive(Clone, Eq, PartialEq, MlsSize, MlsEncode, MlsDecode)]
+#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
+pub(crate) struct Welcome {
+    pub cipher_suite: CipherSuite,
+    pub secrets: Vec<EncryptedGroupSecrets>,
+    #[mls_codec(with = "mls_rs_codec::byte_vec")]
+    pub encrypted_group_info: Vec<u8>,
+}
+
+impl Debug for Welcome {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.debug_struct("Welcome")
+            .field("cipher_suite", &self.cipher_suite)
+            .field("secrets", &self.secrets)
+            .field(
+                "encrypted_group_info",
+                &mls_rs_core::debug::pretty_bytes(&self.encrypted_group_info),
+            )
+            .finish()
+    }
+}
+
+#[derive(Clone, Debug)]
+#[cfg_attr(
+    all(feature = "ffi", not(test)),
+    safer_ffi_gen::ffi_type(clone, opaque)
+)]
+#[non_exhaustive]
+/// Information provided to new members upon joining a group.
+pub struct NewMemberInfo {
+    /// Group info extensions found within the Welcome message used to join
+    /// the group.
+    pub group_info_extensions: ExtensionList,
+}
+
+#[cfg_attr(all(feature = "ffi", not(test)), safer_ffi_gen::safer_ffi_gen)]
+impl NewMemberInfo {
+    pub(crate) fn new(group_info_extensions: ExtensionList) -> Self {
+        let mut new_member_info = Self {
+            group_info_extensions,
+        };
+
+        new_member_info.ungrease();
+
+        new_member_info
+    }
+
+    /// Group info extensions found within the Welcome message used to join
+    /// the group.
+    #[cfg(feature = "ffi")]
+    pub fn group_info_extensions(&self) -> &ExtensionList {
+        &self.group_info_extensions
+    }
+}
+
+/// An MLS end-to-end encrypted group.
+///
+/// # Group Evolution
+///
+/// MLS Groups are evolved via a propose-then-commit system. Each group state
+/// produced by a commit is called an epoch and can produce and consume
+/// application, proposal, and commit messages. A [commit](Group::commit) is used
+/// to advance to the next epoch by applying existing proposals sent in
+/// the current epoch by-reference along with an optional set of proposals
+/// that are included by-value using a [`CommitBuilder`].
+#[cfg_attr(all(feature = "ffi", not(test)), safer_ffi_gen::ffi_type(opaque))]
+#[derive(Clone)]
+pub struct Group<C>
+where
+    C: ClientConfig,
+{
+    config: C,
+    cipher_suite_provider: <C::CryptoProvider as CryptoProvider>::CipherSuiteProvider,
+    state_repo: GroupStateRepository<C::GroupStateStorage, C::KeyPackageRepository>,
+    pub(crate) state: GroupState,
+    epoch_secrets: EpochSecrets,
+    private_tree: TreeKemPrivate,
+    key_schedule: KeySchedule,
+    #[cfg(all(feature = "std", feature = "by_ref_proposal"))]
+    pending_updates: HashMap<HpkePublicKey, (HpkeSecretKey, Option<SignatureSecretKey>)>, // Hash of leaf node hpke public key to secret key
+    #[cfg(all(not(feature = "std"), feature = "by_ref_proposal"))]
+    pending_updates: Vec<(HpkePublicKey, (HpkeSecretKey, Option<SignatureSecretKey>))>,
+    pending_commit: Option<CommitGeneration>,
+    #[cfg(feature = "psk")]
+    previous_psk: Option<PskSecretInput>,
+    #[cfg(test)]
+    pub(crate) commit_modifiers: CommitModifiers,
+    pub(crate) signer: SignatureSecretKey,
+}
+
+#[cfg_attr(all(feature = "ffi", not(test)), safer_ffi_gen::safer_ffi_gen)]
+impl<C> Group<C>
+where
+    C: ClientConfig + Clone,
+{
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(crate) async fn new(
+        config: C,
+        group_id: Option<Vec<u8>>,
+        cipher_suite: CipherSuite,
+        protocol_version: ProtocolVersion,
+        signing_identity: SigningIdentity,
+        group_context_extensions: ExtensionList,
+        signer: SignatureSecretKey,
+    ) -> Result<Self, MlsError> {
+        let cipher_suite_provider = cipher_suite_provider(config.crypto_provider(), cipher_suite)?;
+
+        let (leaf_node, leaf_node_secret) = LeafNode::generate(
+            &cipher_suite_provider,
+            config.leaf_properties(),
+            signing_identity,
+            &signer,
+            config.lifetime(),
+        )
+        .await?;
+
+        let identity_provider = config.identity_provider();
+
+        let leaf_node_validator = LeafNodeValidator::new(
+            &cipher_suite_provider,
+            &identity_provider,
+            Some(&group_context_extensions),
+        );
+
+        leaf_node_validator
+            .check_if_valid(&leaf_node, ValidationContext::Add(None))
+            .await?;
+
+        let (mut public_tree, private_tree) = TreeKemPublic::derive(
+            leaf_node,
+            leaf_node_secret,
+            &config.identity_provider(),
+            &group_context_extensions,
+        )
+        .await?;
+
+        let tree_hash = public_tree.tree_hash(&cipher_suite_provider).await?;
+
+        let group_id = group_id.map(Ok).unwrap_or_else(|| {
+            cipher_suite_provider
+                .random_bytes_vec(cipher_suite_provider.kdf_extract_size())
+                .map_err(|e| MlsError::CryptoProviderError(e.into_any_error()))
+        })?;
+
+        let context = GroupContext::new_group(
+            protocol_version,
+            cipher_suite,
+            group_id,
+            tree_hash,
+            group_context_extensions,
+        );
+
+        let state_repo = GroupStateRepository::new(
+            #[cfg(feature = "prior_epoch")]
+            context.group_id.clone(),
+            config.group_state_storage(),
+            config.key_package_repo(),
+            None,
+        )?;
+
+        let key_schedule_result = KeySchedule::from_random_epoch_secret(
+            &cipher_suite_provider,
+            #[cfg(any(feature = "secret_tree_access", feature = "private_message"))]
+            public_tree.total_leaf_count(),
+        )
+        .await?;
+
+        let confirmation_tag = ConfirmationTag::create(
+            &key_schedule_result.confirmation_key,
+            &vec![].into(),
+            &cipher_suite_provider,
+        )
+        .await?;
+
+        let interim_hash = InterimTranscriptHash::create(
+            &cipher_suite_provider,
+            &vec![].into(),
+            &confirmation_tag,
+        )
+        .await?;
+
+        Ok(Self {
+            config,
+            state: GroupState::new(context, public_tree, interim_hash, confirmation_tag),
+            private_tree,
+            key_schedule: key_schedule_result.key_schedule,
+            #[cfg(feature = "by_ref_proposal")]
+            pending_updates: Default::default(),
+            pending_commit: None,
+            #[cfg(test)]
+            commit_modifiers: Default::default(),
+            epoch_secrets: key_schedule_result.epoch_secrets,
+            state_repo,
+            cipher_suite_provider,
+            #[cfg(feature = "psk")]
+            previous_psk: None,
+            signer,
+        })
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(crate) async fn join(
+        welcome: &MlsMessage,
+        tree_data: Option<ExportedTree<'_>>,
+        config: C,
+        signer: SignatureSecretKey,
+    ) -> Result<(Self, NewMemberInfo), MlsError> {
+        Self::from_welcome_message(
+            welcome,
+            tree_data,
+            config,
+            signer,
+            #[cfg(feature = "psk")]
+            None,
+        )
+        .await
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn from_welcome_message(
+        welcome: &MlsMessage,
+        tree_data: Option<ExportedTree<'_>>,
+        config: C,
+        signer: SignatureSecretKey,
+        #[cfg(feature = "psk")] additional_psk: Option<PskSecretInput>,
+    ) -> Result<(Self, NewMemberInfo), MlsError> {
+        let protocol_version = welcome.version;
+
+        if !config.version_supported(protocol_version) {
+            return Err(MlsError::UnsupportedProtocolVersion(protocol_version));
+        }
+
+        let MlsMessagePayload::Welcome(welcome) = &welcome.payload else {
+            return Err(MlsError::UnexpectedMessageType);
+        };
+
+        let cipher_suite_provider =
+            cipher_suite_provider(config.crypto_provider(), welcome.cipher_suite)?;
+
+        let (encrypted_group_secrets, key_package_generation) =
+            find_key_package_generation(&config.key_package_repo(), &welcome.secrets).await?;
+
+        let key_package_version = key_package_generation.key_package.version;
+
+        if key_package_version != protocol_version {
+            return Err(MlsError::ProtocolVersionMismatch);
+        }
+
+        // Decrypt the encrypted_group_secrets using HPKE with the algorithms indicated by the
+        // cipher suite and the HPKE private key corresponding to the GroupSecrets. If a
+        // PreSharedKeyID is part of the GroupSecrets and the client is not in possession of
+        // the corresponding PSK, return an error
+        let group_secrets = GroupSecrets::decrypt(
+            &cipher_suite_provider,
+            &key_package_generation.init_secret_key,
+            &key_package_generation.key_package.hpke_init_key,
+            &welcome.encrypted_group_info,
+            &encrypted_group_secrets.encrypted_group_secrets,
+        )
+        .await?;
+
+        #[cfg(feature = "psk")]
+        let psk_secret = if let Some(psk) = additional_psk {
+            let psk_id = group_secrets
+                .psks
+                .first()
+                .ok_or(MlsError::UnexpectedPskId)?;
+
+            match &psk_id.key_id {
+                JustPreSharedKeyID::Resumption(r) if r.usage != ResumptionPSKUsage::Application => {
+                    Ok(())
+                }
+                _ => Err(MlsError::UnexpectedPskId),
+            }?;
+
+            let mut psk = psk;
+            psk.id.psk_nonce = psk_id.psk_nonce.clone();
+            PskSecret::calculate(&[psk], &cipher_suite_provider).await?
+        } else {
+            PskResolver::<
+                <C as ClientConfig>::GroupStateStorage,
+                <C as ClientConfig>::KeyPackageRepository,
+                <C as ClientConfig>::PskStore,
+            > {
+                group_context: None,
+                current_epoch: None,
+                prior_epochs: None,
+                psk_store: &config.secret_store(),
+            }
+            .resolve_to_secret(&group_secrets.psks, &cipher_suite_provider)
+            .await?
+        };
+
+        #[cfg(not(feature = "psk"))]
+        let psk_secret = PskSecret::new(&cipher_suite_provider);
+
+        // From the joiner_secret in the decrypted GroupSecrets object and the PSKs specified in
+        // the GroupSecrets, derive the welcome_secret and using that the welcome_key and
+        // welcome_nonce.
+        let welcome_secret = WelcomeSecret::from_joiner_secret(
+            &cipher_suite_provider,
+            &group_secrets.joiner_secret,
+            &psk_secret,
+        )
+        .await?;
+
+        // Use the key and nonce to decrypt the encrypted_group_info field.
+        let decrypted_group_info = welcome_secret
+            .decrypt(&welcome.encrypted_group_info)
+            .await?;
+
+        let group_info = GroupInfo::mls_decode(&mut &**decrypted_group_info)?;
+
+        let public_tree = validate_group_info_joiner(
+            protocol_version,
+            &group_info,
+            tree_data,
+            &config.identity_provider(),
+            &cipher_suite_provider,
+        )
+        .await?;
+
+        // Identify a leaf in the tree array (any even-numbered node) whose leaf_node is identical
+        // to the leaf_node field of the KeyPackage. If no such field exists, return an error. Let
+        // index represent the index of this node among the leaves in the tree, namely the index of
+        // the node in the tree array divided by two.
+        let self_index = public_tree
+            .find_leaf_node(&key_package_generation.key_package.leaf_node)
+            .ok_or(MlsError::WelcomeKeyPackageNotFound)?;
+
+        let used_key_package_ref = key_package_generation.reference;
+
+        let mut private_tree =
+            TreeKemPrivate::new_self_leaf(self_index, key_package_generation.leaf_node_secret_key);
+
+        // If the path_secret value is set in the GroupSecrets object
+        if let Some(path_secret) = group_secrets.path_secret {
+            private_tree
+                .update_secrets(
+                    &cipher_suite_provider,
+                    group_info.signer,
+                    path_secret,
+                    &public_tree,
+                )
+                .await?;
+        }
+
+        // Use the joiner_secret from the GroupSecrets object to generate the epoch secret and
+        // other derived secrets for the current epoch.
+        let key_schedule_result = KeySchedule::from_joiner(
+            &cipher_suite_provider,
+            &group_secrets.joiner_secret,
+            &group_info.group_context,
+            #[cfg(any(feature = "secret_tree_access", feature = "private_message"))]
+            public_tree.total_leaf_count(),
+            &psk_secret,
+        )
+        .await?;
+
+        // Verify the confirmation tag in the GroupInfo using the derived confirmation key and the
+        // confirmed_transcript_hash from the GroupInfo.
+        if !group_info
+            .confirmation_tag
+            .matches(
+                &key_schedule_result.confirmation_key,
+                &group_info.group_context.confirmed_transcript_hash,
+                &cipher_suite_provider,
+            )
+            .await?
+        {
+            return Err(MlsError::InvalidConfirmationTag);
+        }
+
+        Self::join_with(
+            config,
+            group_info,
+            public_tree,
+            key_schedule_result.key_schedule,
+            key_schedule_result.epoch_secrets,
+            private_tree,
+            Some(used_key_package_ref),
+            signer,
+        )
+        .await
+    }
+
+    #[allow(clippy::too_many_arguments)]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn join_with(
+        config: C,
+        group_info: GroupInfo,
+        public_tree: TreeKemPublic,
+        key_schedule: KeySchedule,
+        epoch_secrets: EpochSecrets,
+        private_tree: TreeKemPrivate,
+        used_key_package_ref: Option<KeyPackageRef>,
+        signer: SignatureSecretKey,
+    ) -> Result<(Self, NewMemberInfo), MlsError> {
+        let cs = group_info.group_context.cipher_suite;
+
+        let cs = config
+            .crypto_provider()
+            .cipher_suite_provider(cs)
+            .ok_or(MlsError::UnsupportedCipherSuite(cs))?;
+
+        // Use the confirmed transcript hash and confirmation tag to compute the interim transcript
+        // hash in the new state.
+        let interim_transcript_hash = InterimTranscriptHash::create(
+            &cs,
+            &group_info.group_context.confirmed_transcript_hash,
+            &group_info.confirmation_tag,
+        )
+        .await?;
+
+        let state_repo = GroupStateRepository::new(
+            #[cfg(feature = "prior_epoch")]
+            group_info.group_context.group_id.clone(),
+            config.group_state_storage(),
+            config.key_package_repo(),
+            used_key_package_ref,
+        )?;
+
+        let group = Group {
+            config,
+            state: GroupState::new(
+                group_info.group_context,
+                public_tree,
+                interim_transcript_hash,
+                group_info.confirmation_tag,
+            ),
+            private_tree,
+            key_schedule,
+            #[cfg(feature = "by_ref_proposal")]
+            pending_updates: Default::default(),
+            pending_commit: None,
+            #[cfg(test)]
+            commit_modifiers: Default::default(),
+            epoch_secrets,
+            state_repo,
+            cipher_suite_provider: cs,
+            #[cfg(feature = "psk")]
+            previous_psk: None,
+            signer,
+        };
+
+        Ok((group, NewMemberInfo::new(group_info.extensions)))
+    }
+
+    #[inline(always)]
+    pub(crate) fn current_epoch_tree(&self) -> &TreeKemPublic {
+        &self.state.public_tree
+    }
+
+    /// The current epoch of the group. This value is incremented each
+    /// time a [`Group::commit`] message is processed.
+    #[inline(always)]
+    pub fn current_epoch(&self) -> u64 {
+        self.context().epoch
+    }
+
+    /// Index within the group's state for the local group instance.
+    ///
+    /// This index corresponds to indexes in content descriptions within
+    /// [`ReceivedMessage`].
+    #[inline(always)]
+    pub fn current_member_index(&self) -> u32 {
+        self.private_tree.self_index.0
+    }
+
+    fn current_user_leaf_node(&self) -> Result<&LeafNode, MlsError> {
+        self.current_epoch_tree()
+            .get_leaf_node(self.private_tree.self_index)
+    }
+
+    /// Signing identity currently in use by the local group instance.
+    #[cfg_attr(all(feature = "ffi", not(test)), safer_ffi_gen::safer_ffi_gen_ignore)]
+    pub fn current_member_signing_identity(&self) -> Result<&SigningIdentity, MlsError> {
+        self.current_user_leaf_node().map(|ln| &ln.signing_identity)
+    }
+
+    /// Member at a specific index in the group state.
+    ///
+    /// These indexes correspond to indexes in content descriptions within
+    /// [`ReceivedMessage`].
+    pub fn member_at_index(&self, index: u32) -> Option<Member> {
+        let leaf_index = LeafIndex(index);
+
+        self.current_epoch_tree()
+            .get_leaf_node(leaf_index)
+            .ok()
+            .map(|ln| member_from_leaf_node(ln, leaf_index))
+    }
+
+    #[cfg(feature = "by_ref_proposal")]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn proposal_message(
+        &mut self,
+        proposal: Proposal,
+        authenticated_data: Vec<u8>,
+    ) -> Result<MlsMessage, MlsError> {
+        let sender = Sender::Member(*self.private_tree.self_index);
+
+        let auth_content = AuthenticatedContent::new_signed(
+            &self.cipher_suite_provider,
+            self.context(),
+            sender,
+            Content::Proposal(alloc::boxed::Box::new(proposal.clone())),
+            &self.signer,
+            #[cfg(feature = "private_message")]
+            self.encryption_options()?.control_wire_format(sender),
+            #[cfg(not(feature = "private_message"))]
+            WireFormat::PublicMessage,
+            authenticated_data,
+        )
+        .await?;
+
+        let proposal_ref =
+            ProposalRef::from_content(&self.cipher_suite_provider, &auth_content).await?;
+
+        self.state
+            .proposals
+            .insert(proposal_ref, proposal, auth_content.content.sender);
+
+        self.format_for_wire(auth_content).await
+    }
+
+    /// Unique identifier for this group.
+    pub fn group_id(&self) -> &[u8] {
+        &self.context().group_id
+    }
+
+    fn provisional_private_tree(
+        &self,
+        provisional_state: &ProvisionalState,
+    ) -> Result<(TreeKemPrivate, Option<SignatureSecretKey>), MlsError> {
+        let mut provisional_private_tree = self.private_tree.clone();
+        let self_index = provisional_private_tree.self_index;
+
+        // Remove secret keys for blanked nodes
+        let path = provisional_state
+            .public_tree
+            .nodes
+            .direct_copath(self_index);
+
+        provisional_private_tree
+            .secret_keys
+            .resize(path.len() + 1, None);
+
+        for (i, n) in path.iter().enumerate() {
+            if provisional_state.public_tree.nodes.is_blank(n.path)? {
+                provisional_private_tree.secret_keys[i + 1] = None;
+            }
+        }
+
+        // Apply own update
+        let new_signer = None;
+
+        #[cfg(feature = "by_ref_proposal")]
+        let mut new_signer = new_signer;
+
+        #[cfg(feature = "by_ref_proposal")]
+        for p in &provisional_state.applied_proposals.updates {
+            if p.sender == Sender::Member(*self_index) {
+                let leaf_pk = &p.proposal.leaf_node.public_key;
+
+                // Update the leaf in the private tree if this is our update
+                #[cfg(feature = "std")]
+                let new_leaf_sk_and_signer = self.pending_updates.get(leaf_pk);
+
+                #[cfg(not(feature = "std"))]
+                let new_leaf_sk_and_signer = self
+                    .pending_updates
+                    .iter()
+                    .find_map(|(pk, sk)| (pk == leaf_pk).then_some(sk));
+
+                let new_leaf_sk = new_leaf_sk_and_signer.map(|(sk, _)| sk.clone());
+                new_signer = new_leaf_sk_and_signer.and_then(|(_, sk)| sk.clone());
+
+                provisional_private_tree
+                    .update_leaf(new_leaf_sk.ok_or(MlsError::UpdateErrorNoSecretKey)?);
+
+                break;
+            }
+        }
+
+        Ok((provisional_private_tree, new_signer))
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn encrypt_group_secrets(
+        &self,
+        key_package: &KeyPackage,
+        leaf_index: LeafIndex,
+        joiner_secret: &JoinerSecret,
+        path_secrets: Option<&Vec<Option<PathSecret>>>,
+        #[cfg(feature = "psk")] psks: Vec<PreSharedKeyID>,
+        encrypted_group_info: &[u8],
+    ) -> Result<EncryptedGroupSecrets, MlsError> {
+        let path_secret = path_secrets
+            .map(|secrets| {
+                secrets
+                    .get(
+                        tree_math::leaf_lca_level(*self.private_tree.self_index, *leaf_index)
+                            as usize
+                            - 1,
+                    )
+                    .cloned()
+                    .flatten()
+                    .ok_or(MlsError::InvalidTreeKemPrivateKey)
+            })
+            .transpose()?;
+
+        #[cfg(not(feature = "psk"))]
+        let psks = Vec::new();
+
+        let group_secrets = GroupSecrets {
+            joiner_secret: joiner_secret.clone(),
+            path_secret,
+            psks,
+        };
+
+        let encrypted_group_secrets = group_secrets
+            .encrypt(
+                &self.cipher_suite_provider,
+                &key_package.hpke_init_key,
+                encrypted_group_info,
+            )
+            .await?;
+
+        Ok(EncryptedGroupSecrets {
+            new_member: key_package
+                .to_reference(&self.cipher_suite_provider)
+                .await?,
+            encrypted_group_secrets,
+        })
+    }
+
+    /// Create a proposal message that adds a new member to the group.
+    ///
+    /// `authenticated_data` will be sent unencrypted along with the contents
+    /// of the proposal message.
+    #[cfg(feature = "by_ref_proposal")]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn propose_add(
+        &mut self,
+        key_package: MlsMessage,
+        authenticated_data: Vec<u8>,
+    ) -> Result<MlsMessage, MlsError> {
+        let proposal = self.add_proposal(key_package)?;
+        self.proposal_message(proposal, authenticated_data).await
+    }
+
+    fn add_proposal(&self, key_package: MlsMessage) -> Result<Proposal, MlsError> {
+        Ok(Proposal::Add(alloc::boxed::Box::new(AddProposal {
+            key_package: key_package
+                .into_key_package()
+                .ok_or(MlsError::UnexpectedMessageType)?,
+        })))
+    }
+
+    /// Create a proposal message that updates your own public keys.
+    ///
+    /// This proposal is useful for contributing additional forward secrecy
+    /// and post-compromise security to the group without having to perform
+    /// the necessary computation of a [`Group::commit`].
+    ///
+    /// `authenticated_data` will be sent unencrypted along with the contents
+    /// of the proposal message.
+    #[cfg(feature = "by_ref_proposal")]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn propose_update(
+        &mut self,
+        authenticated_data: Vec<u8>,
+    ) -> Result<MlsMessage, MlsError> {
+        let proposal = self.update_proposal(None, None).await?;
+        self.proposal_message(proposal, authenticated_data).await
+    }
+
+    /// Create a proposal message that updates your own public keys
+    /// as well as your credential.
+    ///
+    /// This proposal is useful for contributing additional forward secrecy
+    /// and post-compromise security to the group without having to perform
+    /// the necessary computation of a [`Group::commit`].
+    ///
+    /// Identity updates are allowed by the group by default assuming that the
+    /// new identity provided is considered
+    /// [valid](crate::IdentityProvider::validate_member)
+    /// by and matches the output of the
+    /// [identity](crate::IdentityProvider)
+    /// function of the current
+    /// [`IdentityProvider`](crate::IdentityProvider).
+    ///
+    /// `authenticated_data` will be sent unencrypted along with the contents
+    /// of the proposal message.
+    #[cfg(feature = "by_ref_proposal")]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn propose_update_with_identity(
+        &mut self,
+        signer: SignatureSecretKey,
+        signing_identity: SigningIdentity,
+        authenticated_data: Vec<u8>,
+    ) -> Result<MlsMessage, MlsError> {
+        let proposal = self
+            .update_proposal(Some(signer), Some(signing_identity))
+            .await?;
+
+        self.proposal_message(proposal, authenticated_data).await
+    }
+
+    #[cfg(feature = "by_ref_proposal")]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn update_proposal(
+        &mut self,
+        signer: Option<SignatureSecretKey>,
+        signing_identity: Option<SigningIdentity>,
+    ) -> Result<Proposal, MlsError> {
+        // Grab a copy of the current node and update it to have new key material
+        let mut new_leaf_node = self.current_user_leaf_node()?.clone();
+
+        let secret_key = new_leaf_node
+            .update(
+                &self.cipher_suite_provider,
+                self.group_id(),
+                self.current_member_index(),
+                self.config.leaf_properties(),
+                signing_identity,
+                signer.as_ref().unwrap_or(&self.signer),
+            )
+            .await?;
+
+        // Store the secret key in the pending updates storage for later
+        #[cfg(feature = "std")]
+        self.pending_updates
+            .insert(new_leaf_node.public_key.clone(), (secret_key, signer));
+
+        #[cfg(not(feature = "std"))]
+        self.pending_updates
+            .push((new_leaf_node.public_key.clone(), (secret_key, signer)));
+
+        Ok(Proposal::Update(UpdateProposal {
+            leaf_node: new_leaf_node,
+        }))
+    }
+
+    /// Create a proposal message that removes an existing member from the
+    /// group.
+    ///
+    /// `authenticated_data` will be sent unencrypted along with the contents
+    /// of the proposal message.
+    #[cfg(feature = "by_ref_proposal")]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn propose_remove(
+        &mut self,
+        index: u32,
+        authenticated_data: Vec<u8>,
+    ) -> Result<MlsMessage, MlsError> {
+        let proposal = self.remove_proposal(index)?;
+        self.proposal_message(proposal, authenticated_data).await
+    }
+
+    fn remove_proposal(&self, index: u32) -> Result<Proposal, MlsError> {
+        let leaf_index = LeafIndex(index);
+
+        // Verify that this leaf is actually in the tree
+        self.current_epoch_tree().get_leaf_node(leaf_index)?;
+
+        Ok(Proposal::Remove(RemoveProposal {
+            to_remove: leaf_index,
+        }))
+    }
+
+    /// Create a proposal message that adds an external pre shared key to the group.
+    ///
+    /// Each group member will need to have the PSK associated with
+    /// [`ExternalPskId`](mls_rs_core::psk::ExternalPskId) installed within
+    /// the [`PreSharedKeyStorage`](mls_rs_core::psk::PreSharedKeyStorage)
+    /// in use by this group upon processing a [commit](Group::commit) that
+    /// contains this proposal.
+    ///
+    /// `authenticated_data` will be sent unencrypted along with the contents
+    /// of the proposal message.
+    #[cfg(all(feature = "by_ref_proposal", feature = "psk"))]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn propose_external_psk(
+        &mut self,
+        psk: ExternalPskId,
+        authenticated_data: Vec<u8>,
+    ) -> Result<MlsMessage, MlsError> {
+        let proposal = self.psk_proposal(JustPreSharedKeyID::External(psk))?;
+        self.proposal_message(proposal, authenticated_data).await
+    }
+
+    #[cfg(feature = "psk")]
+    fn psk_proposal(&self, key_id: JustPreSharedKeyID) -> Result<Proposal, MlsError> {
+        Ok(Proposal::Psk(PreSharedKeyProposal {
+            psk: PreSharedKeyID::new(key_id, &self.cipher_suite_provider)?,
+        }))
+    }
+
+    /// Create a proposal message that adds a pre shared key from a previous
+    /// epoch to the current group state.
+    ///
+    /// Each group member will need to have the secret state from `psk_epoch`.
+    /// In particular, the members who joined between `psk_epoch` and the
+    /// current epoch cannot process a commit containing this proposal.
+    ///
+    /// `authenticated_data` will be sent unencrypted along with the contents
+    /// of the proposal message.
+    #[cfg(all(feature = "by_ref_proposal", feature = "psk"))]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn propose_resumption_psk(
+        &mut self,
+        psk_epoch: u64,
+        authenticated_data: Vec<u8>,
+    ) -> Result<MlsMessage, MlsError> {
+        let key_id = ResumptionPsk {
+            psk_epoch,
+            usage: ResumptionPSKUsage::Application,
+            psk_group_id: PskGroupId(self.group_id().to_vec()),
+        };
+
+        let proposal = self.psk_proposal(JustPreSharedKeyID::Resumption(key_id))?;
+        self.proposal_message(proposal, authenticated_data).await
+    }
+
+    /// Create a proposal message that requests for this group to be
+    /// reinitialized.
+    ///
+    /// Once a [`ReInitProposal`](proposal::ReInitProposal)
+    /// has been sent, another group member can complete reinitialization of
+    /// the group by calling [`Group::get_reinit_client`].
+    ///
+    /// `authenticated_data` will be sent unencrypted along with the contents
+    /// of the proposal message.
+    #[cfg(feature = "by_ref_proposal")]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn propose_reinit(
+        &mut self,
+        group_id: Option<Vec<u8>>,
+        version: ProtocolVersion,
+        cipher_suite: CipherSuite,
+        extensions: ExtensionList,
+        authenticated_data: Vec<u8>,
+    ) -> Result<MlsMessage, MlsError> {
+        let proposal = self.reinit_proposal(group_id, version, cipher_suite, extensions)?;
+        self.proposal_message(proposal, authenticated_data).await
+    }
+
+    fn reinit_proposal(
+        &self,
+        group_id: Option<Vec<u8>>,
+        version: ProtocolVersion,
+        cipher_suite: CipherSuite,
+        extensions: ExtensionList,
+    ) -> Result<Proposal, MlsError> {
+        let group_id = group_id.map(Ok).unwrap_or_else(|| {
+            self.cipher_suite_provider
+                .random_bytes_vec(self.cipher_suite_provider.kdf_extract_size())
+                .map_err(|e| MlsError::CryptoProviderError(e.into_any_error()))
+        })?;
+
+        Ok(Proposal::ReInit(ReInitProposal {
+            group_id,
+            version,
+            cipher_suite,
+            extensions,
+        }))
+    }
+
+    /// Create a proposal message that sets extensions stored in the group
+    /// state.
+    ///
+    /// # Warning
+    ///
+    /// This function does not create a diff that will be applied to the
+    /// current set of extension that are in use. In order for an existing
+    /// extension to not be overwritten by this proposal, it must be included
+    /// in the new set of extensions being proposed.
+    ///
+    ///
+    /// `authenticated_data` will be sent unencrypted along with the contents
+    /// of the proposal message.
+    #[cfg(feature = "by_ref_proposal")]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn propose_group_context_extensions(
+        &mut self,
+        extensions: ExtensionList,
+        authenticated_data: Vec<u8>,
+    ) -> Result<MlsMessage, MlsError> {
+        let proposal = self.group_context_extensions_proposal(extensions);
+        self.proposal_message(proposal, authenticated_data).await
+    }
+
+    fn group_context_extensions_proposal(&self, extensions: ExtensionList) -> Proposal {
+        Proposal::GroupContextExtensions(extensions)
+    }
+
+    /// Create a custom proposal message.
+    ///
+    /// `authenticated_data` will be sent unencrypted along with the contents
+    /// of the proposal message.
+    #[cfg(all(feature = "custom_proposal", feature = "by_ref_proposal"))]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn propose_custom(
+        &mut self,
+        proposal: CustomProposal,
+        authenticated_data: Vec<u8>,
+    ) -> Result<MlsMessage, MlsError> {
+        self.proposal_message(Proposal::Custom(proposal), authenticated_data)
+            .await
+    }
+
+    /// Delete all sent and received proposals cached for commit.
+    #[cfg(feature = "by_ref_proposal")]
+    pub fn clear_proposal_cache(&mut self) {
+        self.state.proposals.clear()
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(crate) async fn format_for_wire(
+        &mut self,
+        content: AuthenticatedContent,
+    ) -> Result<MlsMessage, MlsError> {
+        #[cfg(feature = "private_message")]
+        let payload = if content.wire_format == WireFormat::PrivateMessage {
+            MlsMessagePayload::Cipher(self.create_ciphertext(content).await?)
+        } else {
+            MlsMessagePayload::Plain(self.create_plaintext(content).await?)
+        };
+        #[cfg(not(feature = "private_message"))]
+        let payload = MlsMessagePayload::Plain(self.create_plaintext(content).await?);
+
+        Ok(MlsMessage::new(self.protocol_version(), payload))
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn create_plaintext(
+        &self,
+        auth_content: AuthenticatedContent,
+    ) -> Result<PublicMessage, MlsError> {
+        let membership_tag = if matches!(auth_content.content.sender, Sender::Member(_)) {
+            let tag = self
+                .key_schedule
+                .get_membership_tag(&auth_content, self.context(), &self.cipher_suite_provider)
+                .await?;
+
+            Some(tag)
+        } else {
+            None
+        };
+
+        Ok(PublicMessage {
+            content: auth_content.content,
+            auth: auth_content.auth,
+            membership_tag,
+        })
+    }
+
+    #[cfg(feature = "private_message")]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn create_ciphertext(
+        &mut self,
+        auth_content: AuthenticatedContent,
+    ) -> Result<PrivateMessage, MlsError> {
+        let padding_mode = self.encryption_options()?.padding_mode;
+
+        let mut encryptor = CiphertextProcessor::new(self, self.cipher_suite_provider.clone());
+
+        encryptor.seal(auth_content, padding_mode).await
+    }
+
+    /// Encrypt an application message using the current group state.
+    ///
+    /// `authenticated_data` will be sent unencrypted along with the contents
+    /// of the proposal message.
+    #[cfg(feature = "private_message")]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn encrypt_application_message(
+        &mut self,
+        message: &[u8],
+        authenticated_data: Vec<u8>,
+    ) -> Result<MlsMessage, MlsError> {
+        // A group member that has observed one or more proposals within an epoch MUST send a Commit message
+        // before sending application data
+        #[cfg(feature = "by_ref_proposal")]
+        if !self.state.proposals.is_empty() {
+            return Err(MlsError::CommitRequired);
+        }
+
+        let auth_content = AuthenticatedContent::new_signed(
+            &self.cipher_suite_provider,
+            self.context(),
+            Sender::Member(*self.private_tree.self_index),
+            Content::Application(message.to_vec().into()),
+            &self.signer,
+            WireFormat::PrivateMessage,
+            authenticated_data,
+        )
+        .await?;
+
+        self.format_for_wire(auth_content).await
+    }
+
+    #[cfg(feature = "private_message")]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn decrypt_incoming_ciphertext(
+        &mut self,
+        message: &PrivateMessage,
+    ) -> Result<AuthenticatedContent, MlsError> {
+        let epoch_id = message.epoch;
+
+        let auth_content = if epoch_id == self.context().epoch {
+            let content = CiphertextProcessor::new(self, self.cipher_suite_provider.clone())
+                .open(message)
+                .await?;
+
+            verify_auth_content_signature(
+                &self.cipher_suite_provider,
+                SignaturePublicKeysContainer::RatchetTree(&self.state.public_tree),
+                self.context(),
+                &content,
+                #[cfg(feature = "by_ref_proposal")]
+                &[],
+            )
+            .await?;
+
+            Ok::<_, MlsError>(content)
+        } else {
+            #[cfg(feature = "prior_epoch")]
+            {
+                let epoch = self
+                    .state_repo
+                    .get_epoch_mut(epoch_id)
+                    .await?
+                    .ok_or(MlsError::EpochNotFound)?;
+
+                let content = CiphertextProcessor::new(epoch, self.cipher_suite_provider.clone())
+                    .open(message)
+                    .await?;
+
+                verify_auth_content_signature(
+                    &self.cipher_suite_provider,
+                    SignaturePublicKeysContainer::List(&epoch.signature_public_keys),
+                    &epoch.context,
+                    &content,
+                    #[cfg(feature = "by_ref_proposal")]
+                    &[],
+                )
+                .await?;
+
+                Ok(content)
+            }
+
+            #[cfg(not(feature = "prior_epoch"))]
+            Err(MlsError::EpochNotFound)
+        }?;
+
+        Ok(auth_content)
+    }
+
+    /// Apply a pending commit that was created by [`Group::commit`] or
+    /// [`CommitBuilder::build`].
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn apply_pending_commit(&mut self) -> Result<CommitMessageDescription, MlsError> {
+        let pending_commit = self
+            .pending_commit
+            .clone()
+            .ok_or(MlsError::PendingCommitNotFound)?;
+
+        self.process_commit(pending_commit.content, None).await
+    }
+
+    /// Returns true if a commit has been created but not yet applied
+    /// with [`Group::apply_pending_commit`] or cleared with [`Group::clear_pending_commit`]
+    pub fn has_pending_commit(&self) -> bool {
+        self.pending_commit.is_some()
+    }
+
+    /// Clear the currently pending commit.
+    ///
+    /// This function will automatically be called in the event that a
+    /// commit message is processed using [`Group::process_incoming_message`]
+    /// before [`Group::apply_pending_commit`] is called.
+    pub fn clear_pending_commit(&mut self) {
+        self.pending_commit = None
+    }
+
+    /// Process an inbound message for this group.
+    ///
+    /// # Warning
+    ///
+    /// Changes to the group's state as a result of processing `message` will
+    /// not be persisted by the
+    /// [`GroupStateStorage`](crate::GroupStateStorage)
+    /// in use by this group until [`Group::write_to_storage`] is called.
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    #[inline(never)]
+    pub async fn process_incoming_message(
+        &mut self,
+        message: MlsMessage,
+    ) -> Result<ReceivedMessage, MlsError> {
+        if let Some(pending) = &self.pending_commit {
+            let message_hash = CommitHash::compute(&self.cipher_suite_provider, &message).await?;
+
+            if message_hash == pending.commit_message_hash {
+                let message_description = self.apply_pending_commit().await?;
+
+                return Ok(ReceivedMessage::Commit(message_description));
+            }
+        }
+
+        MessageProcessor::process_incoming_message(
+            self,
+            message,
+            #[cfg(feature = "by_ref_proposal")]
+            true,
+        )
+        .await
+    }
+
+    /// Process an inbound message for this group, providing additional context
+    /// with a message timestamp.
+    ///
+    /// Providing a timestamp is useful when the
+    /// [`IdentityProvider`](crate::IdentityProvider)
+    /// in use by the group can determine validity based on a timestamp.
+    /// For example, this allows for checking X.509 certificate expiration
+    /// at the time when `message` was received by a server rather than when
+    /// a specific client asynchronously received `message`
+    ///
+    /// # Warning
+    ///
+    /// Changes to the group's state as a result of processing `message` will
+    /// not be persisted by the
+    /// [`GroupStateStorage`](crate::GroupStateStorage)
+    /// in use by this group until [`Group::write_to_storage`] is called.
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn process_incoming_message_with_time(
+        &mut self,
+        message: MlsMessage,
+        time: MlsTime,
+    ) -> Result<ReceivedMessage, MlsError> {
+        MessageProcessor::process_incoming_message_with_time(
+            self,
+            message,
+            #[cfg(feature = "by_ref_proposal")]
+            true,
+            Some(time),
+        )
+        .await
+    }
+
+    /// Find a group member by
+    /// [identity](crate::IdentityProvider::identity)
+    ///
+    /// This function determines identity by calling the
+    /// [`IdentityProvider`](crate::IdentityProvider)
+    /// currently in use by the group.
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn member_with_identity(&self, identity: &[u8]) -> Result<Member, MlsError> {
+        let tree = &self.state.public_tree;
+
+        #[cfg(feature = "tree_index")]
+        let index = tree.get_leaf_node_with_identity(identity);
+
+        #[cfg(not(feature = "tree_index"))]
+        let index = tree
+            .get_leaf_node_with_identity(
+                identity,
+                &self.identity_provider(),
+                &self.state.context.extensions,
+            )
+            .await?;
+
+        let index = index.ok_or(MlsError::MemberNotFound)?;
+        let node = self.state.public_tree.get_leaf_node(index)?;
+
+        Ok(member_from_leaf_node(node, index))
+    }
+
+    /// Create a group info message that can be used for external proposals and commits.
+    ///
+    /// The returned `GroupInfo` is suitable for one external commit for the current epoch.
+    /// If `with_tree_in_extension` is set to true, the returned `GroupInfo` contains the
+    /// ratchet tree and therefore contains all information needed to join the group. Otherwise,
+    /// the ratchet tree must be obtained separately, e.g. via
+    /// (ExternalClient::export_tree)[crate::external_client::ExternalGroup::export_tree].
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn group_info_message_allowing_ext_commit(
+        &self,
+        with_tree_in_extension: bool,
+    ) -> Result<MlsMessage, MlsError> {
+        let mut extensions = ExtensionList::new();
+
+        extensions.set_from({
+            self.key_schedule
+                .get_external_key_pair_ext(&self.cipher_suite_provider)
+                .await?
+        })?;
+
+        self.group_info_message_internal(extensions, with_tree_in_extension)
+            .await
+    }
+
+    /// Create a group info message that can be used for external proposals.
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn group_info_message(
+        &self,
+        with_tree_in_extension: bool,
+    ) -> Result<MlsMessage, MlsError> {
+        self.group_info_message_internal(ExtensionList::new(), with_tree_in_extension)
+            .await
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn group_info_message_internal(
+        &self,
+        mut initial_extensions: ExtensionList,
+        with_tree_in_extension: bool,
+    ) -> Result<MlsMessage, MlsError> {
+        if with_tree_in_extension {
+            initial_extensions.set_from(RatchetTreeExt {
+                tree_data: ExportedTree::new(self.state.public_tree.nodes.clone()),
+            })?;
+        }
+
+        let mut info = GroupInfo {
+            group_context: self.context().clone(),
+            extensions: initial_extensions,
+            confirmation_tag: self.state.confirmation_tag.clone(),
+            signer: self.private_tree.self_index,
+            signature: Vec::new(),
+        };
+
+        info.grease(self.cipher_suite_provider())?;
+
+        info.sign(&self.cipher_suite_provider, &self.signer, &())
+            .await?;
+
+        Ok(MlsMessage::new(
+            self.protocol_version(),
+            MlsMessagePayload::GroupInfo(info),
+        ))
+    }
+
+    /// Get the current group context summarizing various information about the group.
+    #[inline(always)]
+    pub fn context(&self) -> &GroupContext {
+        &self.group_state().context
+    }
+
+    /// Get the
+    /// [epoch_authenticator](https://messaginglayersecurity.rocks/mls-protocol/draft-ietf-mls-protocol.html#name-key-schedule)
+    /// of the current epoch.
+    pub fn epoch_authenticator(&self) -> Result<Secret, MlsError> {
+        Ok(self.key_schedule.authentication_secret.clone().into())
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn export_secret(
+        &self,
+        label: &[u8],
+        context: &[u8],
+        len: usize,
+    ) -> Result<Secret, MlsError> {
+        self.key_schedule
+            .export_secret(label, context, len, &self.cipher_suite_provider)
+            .await
+            .map(Into::into)
+    }
+
+    /// Export the current epoch's ratchet tree in serialized format.
+    ///
+    /// This function is used to provide the current group tree to new members
+    /// when the `ratchet_tree_extension` is not used according to [`MlsRules::commit_options`].
+    pub fn export_tree(&self) -> ExportedTree<'_> {
+        ExportedTree::new_borrowed(&self.current_epoch_tree().nodes)
+    }
+
+    /// Current version of the MLS protocol in use by this group.
+    pub fn protocol_version(&self) -> ProtocolVersion {
+        self.context().protocol_version
+    }
+
+    /// Current cipher suite in use by this group.
+    pub fn cipher_suite(&self) -> CipherSuite {
+        self.context().cipher_suite
+    }
+
+    /// Current roster
+    pub fn roster(&self) -> Roster<'_> {
+        self.group_state().public_tree.roster()
+    }
+
+    /// Determines equality of two different groups internal states.
+    /// Useful for testing.
+    ///
+    pub fn equal_group_state(a: &Group<C>, b: &Group<C>) -> bool {
+        a.state == b.state && a.key_schedule == b.key_schedule && a.epoch_secrets == b.epoch_secrets
+    }
+
+    #[cfg(feature = "psk")]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn get_psk(
+        &self,
+        psks: &[ProposalInfo<PreSharedKeyProposal>],
+    ) -> Result<(PskSecret, Vec<PreSharedKeyID>), MlsError> {
+        if let Some(psk) = self.previous_psk.clone() {
+            // TODO consider throwing error if psks not empty
+            let psk_id = vec![psk.id.clone()];
+            let psk = PskSecret::calculate(&[psk], self.cipher_suite_provider()).await?;
+
+            Ok((psk, psk_id))
+        } else {
+            let psks = psks
+                .iter()
+                .map(|psk| psk.proposal.psk.clone())
+                .collect::<Vec<_>>();
+
+            let psk = PskResolver {
+                group_context: Some(self.context()),
+                current_epoch: Some(&self.epoch_secrets),
+                prior_epochs: Some(&self.state_repo),
+                psk_store: &self.config.secret_store(),
+            }
+            .resolve_to_secret(&psks, self.cipher_suite_provider())
+            .await?;
+
+            Ok((psk, psks))
+        }
+    }
+
+    #[cfg(feature = "private_message")]
+    pub(crate) fn encryption_options(&self) -> Result<EncryptionOptions, MlsError> {
+        self.config
+            .mls_rules()
+            .encryption_options(&self.roster(), self.group_context().extensions())
+            .map_err(|e| MlsError::MlsRulesError(e.into_any_error()))
+    }
+
+    #[cfg(not(feature = "psk"))]
+    fn get_psk(&self) -> PskSecret {
+        PskSecret::new(self.cipher_suite_provider())
+    }
+
+    #[cfg(feature = "secret_tree_access")]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    #[inline(never)]
+    pub async fn next_encryption_key(&mut self) -> Result<MessageKey, MlsError> {
+        self.epoch_secrets
+            .secret_tree
+            .next_message_key(
+                &self.cipher_suite_provider,
+                crate::tree_kem::node::NodeIndex::from(self.private_tree.self_index),
+                KeyType::Application,
+            )
+            .await
+    }
+
+    #[cfg(feature = "secret_tree_access")]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn derive_decryption_key(
+        &mut self,
+        sender: u32,
+        generation: u32,
+    ) -> Result<MessageKey, MlsError> {
+        self.epoch_secrets
+            .secret_tree
+            .message_key_generation(
+                &self.cipher_suite_provider,
+                crate::tree_kem::node::NodeIndex::from(sender),
+                KeyType::Application,
+                generation,
+            )
+            .await
+    }
+}
+
+#[cfg(feature = "private_message")]
+impl<C> GroupStateProvider for Group<C>
+where
+    C: ClientConfig + Clone,
+{
+    fn group_context(&self) -> &GroupContext {
+        self.context()
+    }
+
+    fn self_index(&self) -> LeafIndex {
+        self.private_tree.self_index
+    }
+
+    fn epoch_secrets_mut(&mut self) -> &mut EpochSecrets {
+        &mut self.epoch_secrets
+    }
+
+    fn epoch_secrets(&self) -> &EpochSecrets {
+        &self.epoch_secrets
+    }
+}
+
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+#[cfg_attr(all(target_arch = "wasm32", mls_build_async), maybe_async::must_be_async(?Send))]
+#[cfg_attr(
+    all(not(target_arch = "wasm32"), mls_build_async),
+    maybe_async::must_be_async
+)]
+impl<C> MessageProcessor for Group<C>
+where
+    C: ClientConfig + Clone,
+{
+    type MlsRules = C::MlsRules;
+    type IdentityProvider = C::IdentityProvider;
+    type PreSharedKeyStorage = C::PskStore;
+    type OutputType = ReceivedMessage;
+    type CipherSuiteProvider = <C::CryptoProvider as CryptoProvider>::CipherSuiteProvider;
+
+    #[cfg(feature = "private_message")]
+    fn self_index(&self) -> Option<LeafIndex> {
+        Some(self.private_tree.self_index)
+    }
+
+    #[cfg(feature = "private_message")]
+    async fn process_ciphertext(
+        &mut self,
+        cipher_text: &PrivateMessage,
+    ) -> Result<EventOrContent<Self::OutputType>, MlsError> {
+        self.decrypt_incoming_ciphertext(cipher_text)
+            .await
+            .map(EventOrContent::Content)
+    }
+
+    async fn verify_plaintext_authentication(
+        &self,
+        message: PublicMessage,
+    ) -> Result<EventOrContent<Self::OutputType>, MlsError> {
+        let auth_content = verify_plaintext_authentication(
+            &self.cipher_suite_provider,
+            message,
+            Some(&self.key_schedule),
+            Some(self.private_tree.self_index),
+            &self.state,
+        )
+        .await?;
+
+        Ok(EventOrContent::Content(auth_content))
+    }
+
+    async fn apply_update_path(
+        &mut self,
+        sender: LeafIndex,
+        update_path: &ValidatedUpdatePath,
+        provisional_state: &mut ProvisionalState,
+    ) -> Result<Option<(TreeKemPrivate, PathSecret)>, MlsError> {
+        // Update the private tree to create a provisional private tree
+        let (mut provisional_private_tree, new_signer) =
+            self.provisional_private_tree(provisional_state)?;
+
+        if let Some(signer) = new_signer {
+            self.signer = signer;
+        }
+
+        provisional_state
+            .public_tree
+            .apply_update_path(
+                sender,
+                update_path,
+                &provisional_state.group_context.extensions,
+                self.identity_provider(),
+                self.cipher_suite_provider(),
+            )
+            .await?;
+
+        if let Some(pending) = &self.pending_commit {
+            Ok(Some((
+                pending.pending_private_tree.clone(),
+                pending.pending_commit_secret.clone(),
+            )))
+        } else {
+            // Update the tree hash to get context for decryption
+            provisional_state.group_context.tree_hash = provisional_state
+                .public_tree
+                .tree_hash(&self.cipher_suite_provider)
+                .await?;
+
+            let context_bytes = provisional_state.group_context.mls_encode_to_vec()?;
+
+            TreeKem::new(
+                &mut provisional_state.public_tree,
+                &mut provisional_private_tree,
+            )
+            .decap(
+                sender,
+                update_path,
+                &provisional_state.indexes_of_added_kpkgs,
+                &context_bytes,
+                &self.cipher_suite_provider,
+            )
+            .await
+            .map(|root_secret| Some((provisional_private_tree, root_secret)))
+        }
+    }
+
+    async fn update_key_schedule(
+        &mut self,
+        secrets: Option<(TreeKemPrivate, PathSecret)>,
+        interim_transcript_hash: InterimTranscriptHash,
+        confirmation_tag: &ConfirmationTag,
+        provisional_state: ProvisionalState,
+    ) -> Result<(), MlsError> {
+        let commit_secret = if let Some(secrets) = secrets {
+            self.private_tree = secrets.0;
+            secrets.1
+        } else {
+            PathSecret::empty(&self.cipher_suite_provider)
+        };
+
+        // Use the commit_secret, the psk_secret, the provisional GroupContext, and the init secret
+        // from the previous epoch (or from the external init) to compute the epoch secret and
+        // derived secrets for the new epoch
+
+        let key_schedule = match provisional_state
+            .applied_proposals
+            .external_initializations
+            .first()
+            .cloned()
+        {
+            Some(ext_init) if self.pending_commit.is_none() => {
+                self.key_schedule
+                    .derive_for_external(&ext_init.proposal.kem_output, &self.cipher_suite_provider)
+                    .await?
+            }
+            _ => self.key_schedule.clone(),
+        };
+
+        #[cfg(feature = "psk")]
+        let (psk, _) = self
+            .get_psk(&provisional_state.applied_proposals.psks)
+            .await?;
+
+        #[cfg(not(feature = "psk"))]
+        let psk = self.get_psk();
+
+        let key_schedule_result = KeySchedule::from_key_schedule(
+            &key_schedule,
+            &commit_secret,
+            &provisional_state.group_context,
+            #[cfg(any(feature = "secret_tree_access", feature = "private_message"))]
+            provisional_state.public_tree.total_leaf_count(),
+            &psk,
+            &self.cipher_suite_provider,
+        )
+        .await?;
+
+        // Use the confirmation_key for the new epoch to compute the confirmation tag for
+        // this message, as described below, and verify that it is the same as the
+        // confirmation_tag field in the MlsPlaintext object.
+        let new_confirmation_tag = ConfirmationTag::create(
+            &key_schedule_result.confirmation_key,
+            &provisional_state.group_context.confirmed_transcript_hash,
+            &self.cipher_suite_provider,
+        )
+        .await?;
+
+        if &new_confirmation_tag != confirmation_tag {
+            return Err(MlsError::InvalidConfirmationTag);
+        }
+
+        #[cfg(feature = "prior_epoch")]
+        let signature_public_keys = self
+            .state
+            .public_tree
+            .leaves()
+            .map(|l| l.map(|n| n.signing_identity.signature_key.clone()))
+            .collect();
+
+        #[cfg(feature = "prior_epoch")]
+        let past_epoch = PriorEpoch {
+            context: self.context().clone(),
+            self_index: self.private_tree.self_index,
+            secrets: self.epoch_secrets.clone(),
+            signature_public_keys,
+        };
+
+        #[cfg(feature = "prior_epoch")]
+        self.state_repo.insert(past_epoch).await?;
+
+        self.epoch_secrets = key_schedule_result.epoch_secrets;
+        self.state.context = provisional_state.group_context;
+        self.state.interim_transcript_hash = interim_transcript_hash;
+        self.key_schedule = key_schedule_result.key_schedule;
+        self.state.public_tree = provisional_state.public_tree;
+        self.state.confirmation_tag = new_confirmation_tag;
+
+        // Clear the proposals list
+        #[cfg(feature = "by_ref_proposal")]
+        self.state.proposals.clear();
+
+        // Clear the pending updates list
+        #[cfg(feature = "by_ref_proposal")]
+        {
+            self.pending_updates = Default::default();
+        }
+
+        self.pending_commit = None;
+
+        Ok(())
+    }
+
+    fn mls_rules(&self) -> Self::MlsRules {
+        self.config.mls_rules()
+    }
+
+    fn identity_provider(&self) -> Self::IdentityProvider {
+        self.config.identity_provider()
+    }
+
+    fn psk_storage(&self) -> Self::PreSharedKeyStorage {
+        self.config.secret_store()
+    }
+
+    fn group_state(&self) -> &GroupState {
+        &self.state
+    }
+
+    fn group_state_mut(&mut self) -> &mut GroupState {
+        &mut self.state
+    }
+
+    fn can_continue_processing(&self, provisional_state: &ProvisionalState) -> bool {
+        !(provisional_state
+            .applied_proposals
+            .removals
+            .iter()
+            .any(|p| p.proposal.to_remove == self.private_tree.self_index)
+            && self.pending_commit.is_none())
+    }
+
+    #[cfg(feature = "private_message")]
+    fn min_epoch_available(&self) -> Option<u64> {
+        None
+    }
+
+    fn cipher_suite_provider(&self) -> &Self::CipherSuiteProvider {
+        &self.cipher_suite_provider
+    }
+}
+
+#[cfg(test)]
+pub(crate) mod test_utils;
+
+#[cfg(test)]
+mod tests {
+    use crate::{
+        client::test_utils::{
+            test_client_with_key_pkg, TestClientBuilder, TEST_CIPHER_SUITE,
+            TEST_CUSTOM_PROPOSAL_TYPE, TEST_PROTOCOL_VERSION,
+        },
+        client_builder::{test_utils::TestClientConfig, ClientBuilder, MlsConfig},
+        crypto::test_utils::TestCryptoProvider,
+        group::{
+            mls_rules::{CommitDirection, CommitSource},
+            proposal_filter::ProposalBundle,
+        },
+        identity::{
+            basic::BasicIdentityProvider,
+            test_utils::{get_test_signing_identity, BasicWithCustomProvider},
+        },
+        key_package::test_utils::test_key_package_message,
+        mls_rules::CommitOptions,
+        tree_kem::{
+            leaf_node::{test_utils::get_test_capabilities, LeafNodeSource},
+            UpdatePathNode,
+        },
+    };
+
+    #[cfg(any(feature = "private_message", feature = "custom_proposal"))]
+    use crate::group::mls_rules::DefaultMlsRules;
+
+    #[cfg(feature = "prior_epoch")]
+    use crate::group::padding::PaddingMode;
+
+    use crate::{extension::RequiredCapabilitiesExt, key_package::test_utils::test_key_package};
+
+    #[cfg(all(feature = "by_ref_proposal", feature = "custom_proposal"))]
+    use super::test_utils::test_group_custom_config;
+
+    #[cfg(feature = "psk")]
+    use crate::{client::Client, psk::PreSharedKey};
+
+    #[cfg(any(feature = "by_ref_proposal", feature = "private_message"))]
+    use crate::group::test_utils::random_bytes;
+
+    #[cfg(feature = "by_ref_proposal")]
+    use crate::{
+        extension::test_utils::TestExtension, identity::test_utils::get_test_basic_credential,
+        time::MlsTime,
+    };
+
+    use super::{
+        test_utils::{
+            get_test_25519_key, get_test_groups_with_features, group_extensions, process_commit,
+            test_group, test_group_custom, test_n_member_group, TestGroup, TEST_GROUP,
+        },
+        *,
+    };
+
+    use assert_matches::assert_matches;
+
+    use mls_rs_core::extension::{Extension, ExtensionType};
+    use mls_rs_core::identity::{Credential, CredentialType, CustomCredential};
+
+    #[cfg(feature = "by_ref_proposal")]
+    use mls_rs_core::identity::CertificateChain;
+
+    #[cfg(feature = "state_update")]
+    use itertools::Itertools;
+
+    #[cfg(feature = "state_update")]
+    use alloc::format;
+
+    #[cfg(feature = "by_ref_proposal")]
+    use crate::{crypto::test_utils::test_cipher_suite_provider, extension::ExternalSendersExt};
+
+    #[cfg(any(feature = "private_message", feature = "state_update"))]
+    use super::test_utils::test_member;
+
+    use mls_rs_core::extension::MlsExtension;
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_create_group() {
+        for (protocol_version, cipher_suite) in ProtocolVersion::all().flat_map(|p| {
+            TestCryptoProvider::all_supported_cipher_suites()
+                .into_iter()
+                .map(move |cs| (p, cs))
+        }) {
+            let test_group = test_group(protocol_version, cipher_suite).await;
+            let group = test_group.group;
+
+            assert_eq!(group.cipher_suite(), cipher_suite);
+            assert_eq!(group.state.context.epoch, 0);
+            assert_eq!(group.state.context.group_id, TEST_GROUP.to_vec());
+            assert_eq!(group.state.context.extensions, group_extensions());
+
+            assert_eq!(
+                group.state.context.confirmed_transcript_hash,
+                ConfirmedTranscriptHash::from(vec![])
+            );
+
+            #[cfg(feature = "private_message")]
+            assert!(group.state.proposals.is_empty());
+
+            #[cfg(feature = "by_ref_proposal")]
+            assert!(group.pending_updates.is_empty());
+
+            assert!(!group.has_pending_commit());
+
+            assert_eq!(
+                group.private_tree.self_index.0,
+                group.current_member_index()
+            );
+        }
+    }
+
+    #[cfg(feature = "private_message")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_pending_proposals_application_data() {
+        let mut test_group = test_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await;
+
+        // Create a proposal
+        let (bob_key_package, _) =
+            test_member(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, b"bob").await;
+
+        let proposal = test_group
+            .group
+            .add_proposal(bob_key_package.key_package_message())
+            .unwrap();
+
+        test_group
+            .group
+            .proposal_message(proposal, vec![])
+            .await
+            .unwrap();
+
+        // We should not be able to send application messages until a commit happens
+        let res = test_group
+            .group
+            .encrypt_application_message(b"test", vec![])
+            .await;
+
+        assert_matches!(res, Err(MlsError::CommitRequired));
+
+        // We should be able to send application messages after a commit
+        test_group.group.commit(vec![]).await.unwrap();
+
+        assert!(test_group.group.has_pending_commit());
+
+        test_group.group.apply_pending_commit().await.unwrap();
+
+        let res = test_group
+            .group
+            .encrypt_application_message(b"test", vec![])
+            .await;
+
+        assert!(res.is_ok());
+    }
+
+    #[cfg(feature = "by_ref_proposal")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_update_proposals() {
+        let new_extension = TestExtension { foo: 10 };
+        let mut extension_list = ExtensionList::default();
+        extension_list.set_from(new_extension).unwrap();
+
+        let mut test_group = test_group_custom(
+            TEST_PROTOCOL_VERSION,
+            TEST_CIPHER_SUITE,
+            vec![42.into()],
+            Some(extension_list.clone()),
+            None,
+        )
+        .await;
+
+        let existing_leaf = test_group.group.current_user_leaf_node().unwrap().clone();
+
+        // Create an update proposal
+        let proposal = test_group.update_proposal().await;
+
+        let update = match proposal {
+            Proposal::Update(update) => update,
+            _ => panic!("non update proposal found"),
+        };
+
+        assert_ne!(update.leaf_node.public_key, existing_leaf.public_key);
+
+        assert_eq!(
+            update.leaf_node.signing_identity,
+            existing_leaf.signing_identity
+        );
+
+        assert_eq!(update.leaf_node.ungreased_extensions(), extension_list);
+        assert_eq!(
+            update.leaf_node.ungreased_capabilities().sorted(),
+            Capabilities {
+                extensions: vec![42.into()],
+                ..get_test_capabilities()
+            }
+            .sorted()
+        );
+    }
+
+    #[cfg(feature = "by_ref_proposal")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_invalid_commit_self_update() {
+        let mut test_group = test_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await;
+
+        // Create an update proposal
+        let proposal_msg = test_group.group.propose_update(vec![]).await.unwrap();
+
+        let proposal = match proposal_msg.into_plaintext().unwrap().content.content {
+            Content::Proposal(p) => p,
+            _ => panic!("found non-proposal message"),
+        };
+
+        let update_leaf = match *proposal {
+            Proposal::Update(u) => u.leaf_node,
+            _ => panic!("found proposal message that isn't an update"),
+        };
+
+        test_group.group.commit(vec![]).await.unwrap();
+        test_group.group.apply_pending_commit().await.unwrap();
+
+        // The leaf node should not be the one from the update, because the committer rejects it
+        assert_ne!(
+            &update_leaf,
+            test_group.group.current_user_leaf_node().unwrap()
+        );
+    }
+
+    #[cfg(feature = "by_ref_proposal")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn update_proposal_with_bad_key_package_is_ignored_when_committing() {
+        let (mut alice_group, mut bob_group) =
+            test_two_member_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, true).await;
+
+        let mut proposal = alice_group.update_proposal().await;
+
+        if let Proposal::Update(ref mut update) = proposal {
+            update.leaf_node.signature = random_bytes(32);
+        } else {
+            panic!("Invalid update proposal")
+        }
+
+        let proposal_message = alice_group
+            .group
+            .proposal_message(proposal.clone(), vec![])
+            .await
+            .unwrap();
+
+        let proposal_plaintext = match proposal_message.payload {
+            MlsMessagePayload::Plain(p) => p,
+            _ => panic!("Unexpected non-plaintext message"),
+        };
+
+        let proposal_ref = ProposalRef::from_content(
+            &bob_group.group.cipher_suite_provider,
+            &proposal_plaintext.clone().into(),
+        )
+        .await
+        .unwrap();
+
+        // Hack bob's receipt of the proposal
+        bob_group.group.state.proposals.insert(
+            proposal_ref,
+            proposal,
+            proposal_plaintext.content.sender,
+        );
+
+        let commit_output = bob_group.group.commit(vec![]).await.unwrap();
+
+        assert_matches!(
+            commit_output.commit_message,
+            MlsMessage {
+                payload: MlsMessagePayload::Plain(
+                    PublicMessage {
+                        content: FramedContent {
+                            content: Content::Commit(c),
+                            ..
+                        },
+                        ..
+                    }),
+                ..
+            } if c.proposals.is_empty()
+        );
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn test_two_member_group(
+        protocol_version: ProtocolVersion,
+        cipher_suite: CipherSuite,
+        tree_ext: bool,
+    ) -> (TestGroup, TestGroup) {
+        let mut test_group = test_group_custom(
+            protocol_version,
+            cipher_suite,
+            Default::default(),
+            None,
+            Some(CommitOptions::new().with_ratchet_tree_extension(tree_ext)),
+        )
+        .await;
+
+        let (bob_test_group, _) = test_group.join("bob").await;
+
+        assert!(Group::equal_group_state(
+            &test_group.group,
+            &bob_test_group.group
+        ));
+
+        (test_group, bob_test_group)
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_welcome_processing_exported_tree() {
+        test_two_member_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, false).await;
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_welcome_processing_tree_extension() {
+        test_two_member_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, true).await;
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_welcome_processing_missing_tree() {
+        let mut test_group = test_group_custom(
+            TEST_PROTOCOL_VERSION,
+            TEST_CIPHER_SUITE,
+            Default::default(),
+            None,
+            Some(CommitOptions::new().with_ratchet_tree_extension(false)),
+        )
+        .await;
+
+        let (bob_client, bob_key_package) =
+            test_client_with_key_pkg(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, "bob").await;
+
+        // Add bob to the group
+        let commit_output = test_group
+            .group
+            .commit_builder()
+            .add_member(bob_key_package)
+            .unwrap()
+            .build()
+            .await
+            .unwrap();
+
+        // Group from Bob's perspective
+        let bob_group = Group::join(
+            &commit_output.welcome_messages[0],
+            None,
+            bob_client.config,
+            bob_client.signer.unwrap(),
+        )
+        .await
+        .map(|_| ());
+
+        assert_matches!(bob_group, Err(MlsError::RatchetTreeNotFound));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_group_context_ext_proposal_create() {
+        let test_group = test_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await;
+
+        let mut extension_list = ExtensionList::new();
+        extension_list
+            .set_from(RequiredCapabilitiesExt {
+                extensions: vec![42.into()],
+                proposals: vec![],
+                credentials: vec![],
+            })
+            .unwrap();
+
+        let proposal = test_group
+            .group
+            .group_context_extensions_proposal(extension_list.clone());
+
+        assert_matches!(proposal, Proposal::GroupContextExtensions(ext) if ext == extension_list);
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn group_context_extension_proposal_test(
+        ext_list: ExtensionList,
+    ) -> (TestGroup, Result<MlsMessage, MlsError>) {
+        let protocol_version = TEST_PROTOCOL_VERSION;
+        let cipher_suite = TEST_CIPHER_SUITE;
+
+        let mut test_group =
+            test_group_custom(protocol_version, cipher_suite, vec![42.into()], None, None).await;
+
+        let commit = test_group
+            .group
+            .commit_builder()
+            .set_group_context_ext(ext_list)
+            .unwrap()
+            .build()
+            .await
+            .map(|commit_output| commit_output.commit_message);
+
+        (test_group, commit)
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_group_context_ext_proposal_commit() {
+        let mut extension_list = ExtensionList::new();
+
+        extension_list
+            .set_from(RequiredCapabilitiesExt {
+                extensions: vec![42.into()],
+                proposals: vec![],
+                credentials: vec![],
+            })
+            .unwrap();
+
+        let (mut test_group, _) =
+            group_context_extension_proposal_test(extension_list.clone()).await;
+
+        #[cfg(feature = "state_update")]
+        {
+            let update = test_group.group.apply_pending_commit().await.unwrap();
+            assert!(update.state_update.active);
+        }
+
+        #[cfg(not(feature = "state_update"))]
+        test_group.group.apply_pending_commit().await.unwrap();
+
+        assert_eq!(test_group.group.state.context.extensions, extension_list)
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_group_context_ext_proposal_invalid() {
+        let mut extension_list = ExtensionList::new();
+        extension_list
+            .set_from(RequiredCapabilitiesExt {
+                extensions: vec![999.into()],
+                proposals: vec![],
+                credentials: vec![],
+            })
+            .unwrap();
+
+        let (_, commit) = group_context_extension_proposal_test(extension_list.clone()).await;
+
+        assert_matches!(
+            commit,
+            Err(MlsError::RequiredExtensionNotFound(a)) if a == 999.into()
+        );
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn make_group_with_required_capabilities(
+        required_caps: RequiredCapabilitiesExt,
+    ) -> Result<Group<TestClientConfig>, MlsError> {
+        test_client_with_key_pkg(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, "alice")
+            .await
+            .0
+            .create_group(core::iter::once(required_caps.into_extension().unwrap()).collect())
+            .await
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn creating_group_with_member_not_supporting_required_credential_type_fails() {
+        let group_creation = make_group_with_required_capabilities(RequiredCapabilitiesExt {
+            credentials: vec![CredentialType::BASIC, CredentialType::X509],
+            ..Default::default()
+        })
+        .await
+        .map(|_| ());
+
+        assert_matches!(
+            group_creation,
+            Err(MlsError::RequiredCredentialNotFound(CredentialType::X509))
+        );
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn creating_group_with_member_not_supporting_required_extension_type_fails() {
+        const EXTENSION_TYPE: ExtensionType = ExtensionType::new(33);
+
+        let group_creation = make_group_with_required_capabilities(RequiredCapabilitiesExt {
+            extensions: vec![EXTENSION_TYPE],
+            ..Default::default()
+        })
+        .await
+        .map(|_| ());
+
+        assert_matches!(
+            group_creation,
+            Err(MlsError::RequiredExtensionNotFound(EXTENSION_TYPE))
+        );
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn creating_group_with_member_not_supporting_required_proposal_type_fails() {
+        const PROPOSAL_TYPE: ProposalType = ProposalType::new(33);
+
+        let group_creation = make_group_with_required_capabilities(RequiredCapabilitiesExt {
+            proposals: vec![PROPOSAL_TYPE],
+            ..Default::default()
+        })
+        .await
+        .map(|_| ());
+
+        assert_matches!(
+            group_creation,
+            Err(MlsError::RequiredProposalNotFound(PROPOSAL_TYPE))
+        );
+    }
+
+    #[cfg(feature = "by_ref_proposal")]
+    #[cfg(not(target_arch = "wasm32"))]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn creating_group_with_member_not_supporting_external_sender_credential_fails() {
+        let ext_senders = make_x509_external_senders_ext()
+            .await
+            .into_extension()
+            .unwrap();
+
+        let group_creation =
+            test_client_with_key_pkg(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, "alice")
+                .await
+                .0
+                .create_group(core::iter::once(ext_senders).collect())
+                .await
+                .map(|_| ());
+
+        assert_matches!(
+            group_creation,
+            Err(MlsError::RequiredCredentialNotFound(CredentialType::X509))
+        );
+    }
+
+    #[cfg(all(not(target_arch = "wasm32"), feature = "private_message"))]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_group_encrypt_plaintext_padding() {
+        let protocol_version = TEST_PROTOCOL_VERSION;
+        // This test requires a cipher suite whose signatures are not variable in length.
+        let cipher_suite = CipherSuite::CURVE25519_AES128;
+
+        let mut test_group = test_group_custom_config(protocol_version, cipher_suite, |b| {
+            b.mls_rules(
+                DefaultMlsRules::default()
+                    .with_encryption_options(EncryptionOptions::new(true, PaddingMode::None)),
+            )
+        })
+        .await;
+
+        let without_padding = test_group
+            .group
+            .encrypt_application_message(&random_bytes(150), vec![])
+            .await
+            .unwrap();
+
+        let mut test_group =
+            test_group_custom_config(protocol_version, cipher_suite, |b| {
+                b.mls_rules(DefaultMlsRules::default().with_encryption_options(
+                    EncryptionOptions::new(true, PaddingMode::StepFunction),
+                ))
+            })
+            .await;
+
+        let with_padding = test_group
+            .group
+            .encrypt_application_message(&random_bytes(150), vec![])
+            .await
+            .unwrap();
+
+        assert!(with_padding.mls_encoded_len() > without_padding.mls_encoded_len());
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn external_commit_requires_external_pub_extension() {
+        let protocol_version = TEST_PROTOCOL_VERSION;
+        let cipher_suite = TEST_CIPHER_SUITE;
+        let group = test_group(protocol_version, cipher_suite).await;
+
+        let info = group
+            .group
+            .group_info_message(false)
+            .await
+            .unwrap()
+            .into_group_info()
+            .unwrap();
+
+        let info_msg = MlsMessage::new(protocol_version, MlsMessagePayload::GroupInfo(info));
+
+        let signing_identity = group
+            .group
+            .current_member_signing_identity()
+            .unwrap()
+            .clone();
+
+        let res = external_commit::ExternalCommitBuilder::new(
+            group.group.signer,
+            signing_identity,
+            group.group.config,
+        )
+        .build(info_msg)
+        .await
+        .map(|_| {});
+
+        assert_matches!(res, Err(MlsError::MissingExternalPubExtension));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn external_commit_via_commit_options_round_trip() {
+        let mut group = test_group_custom(
+            TEST_PROTOCOL_VERSION,
+            TEST_CIPHER_SUITE,
+            vec![],
+            None,
+            CommitOptions::default()
+                .with_allow_external_commit(true)
+                .into(),
+        )
+        .await;
+
+        let commit_output = group.group.commit(vec![]).await.unwrap();
+
+        let (test_client, _) =
+            test_client_with_key_pkg(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, "bob").await;
+
+        test_client
+            .external_commit_builder()
+            .unwrap()
+            .build(commit_output.external_commit_group_info.unwrap())
+            .await
+            .unwrap();
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_path_update_preference() {
+        let protocol_version = TEST_PROTOCOL_VERSION;
+        let cipher_suite = TEST_CIPHER_SUITE;
+
+        let mut test_group = test_group_custom(
+            protocol_version,
+            cipher_suite,
+            Default::default(),
+            None,
+            Some(CommitOptions::new()),
+        )
+        .await;
+
+        let test_key_package =
+            test_key_package_message(protocol_version, cipher_suite, "alice").await;
+
+        test_group
+            .group
+            .commit_builder()
+            .add_member(test_key_package.clone())
+            .unwrap()
+            .build()
+            .await
+            .unwrap();
+
+        assert!(test_group
+            .group
+            .pending_commit
+            .unwrap()
+            .pending_commit_secret
+            .iter()
+            .all(|x| x == &0));
+
+        let mut test_group = test_group_custom(
+            protocol_version,
+            cipher_suite,
+            Default::default(),
+            None,
+            Some(CommitOptions::new().with_path_required(true)),
+        )
+        .await;
+
+        test_group
+            .group
+            .commit_builder()
+            .add_member(test_key_package)
+            .unwrap()
+            .build()
+            .await
+            .unwrap();
+
+        assert!(!test_group
+            .group
+            .pending_commit
+            .unwrap()
+            .pending_commit_secret
+            .iter()
+            .all(|x| x == &0));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_path_update_preference_override() {
+        let protocol_version = TEST_PROTOCOL_VERSION;
+        let cipher_suite = TEST_CIPHER_SUITE;
+
+        let mut test_group = test_group_custom(
+            protocol_version,
+            cipher_suite,
+            Default::default(),
+            None,
+            Some(CommitOptions::new()),
+        )
+        .await;
+
+        test_group.group.commit(vec![]).await.unwrap();
+
+        assert!(!test_group
+            .group
+            .pending_commit
+            .unwrap()
+            .pending_commit_secret
+            .iter()
+            .all(|x| x == &0));
+    }
+
+    #[cfg(feature = "private_message")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn group_rejects_unencrypted_application_message() {
+        let protocol_version = TEST_PROTOCOL_VERSION;
+        let cipher_suite = TEST_CIPHER_SUITE;
+
+        let mut alice = test_group(protocol_version, cipher_suite).await;
+        let (mut bob, _) = alice.join("bob").await;
+
+        let message = alice
+            .make_plaintext(Content::Application(b"hello".to_vec().into()))
+            .await;
+
+        let res = bob.group.process_incoming_message(message).await;
+
+        assert_matches!(res, Err(MlsError::UnencryptedApplicationMessage));
+    }
+
+    #[cfg(feature = "state_update")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_state_update() {
+        let protocol_version = TEST_PROTOCOL_VERSION;
+        let cipher_suite = TEST_CIPHER_SUITE;
+
+        // Create a group with 10 members
+        let mut alice = test_group(protocol_version, cipher_suite).await;
+        let (mut bob, _) = alice.join("bob").await;
+        let mut leaves = vec![];
+
+        for i in 0..8 {
+            let (group, commit) = alice.join(&format!("charlie{i}")).await;
+            leaves.push(group.group.current_user_leaf_node().unwrap().clone());
+            bob.process_message(commit).await.unwrap();
+        }
+
+        // Create many proposals, make Alice commit them
+
+        let update_message = bob.group.propose_update(vec![]).await.unwrap();
+
+        alice.process_message(update_message).await.unwrap();
+
+        let external_psk_ids: Vec<ExternalPskId> = (0..5)
+            .map(|i| {
+                let external_id = ExternalPskId::new(vec![i]);
+
+                alice
+                    .group
+                    .config
+                    .secret_store()
+                    .insert(ExternalPskId::new(vec![i]), PreSharedKey::from(vec![i]));
+
+                bob.group
+                    .config
+                    .secret_store()
+                    .insert(ExternalPskId::new(vec![i]), PreSharedKey::from(vec![i]));
+
+                external_id
+            })
+            .collect();
+
+        let mut commit_builder = alice.group.commit_builder();
+
+        for external_psk in external_psk_ids {
+            commit_builder = commit_builder.add_external_psk(external_psk).unwrap();
+        }
+
+        for index in [2, 5, 6] {
+            commit_builder = commit_builder.remove_member(index).unwrap();
+        }
+
+        for i in 0..5 {
+            let (key_package, _) = test_member(
+                protocol_version,
+                cipher_suite,
+                format!("dave{i}").as_bytes(),
+            )
+            .await;
+
+            commit_builder = commit_builder
+                .add_member(key_package.key_package_message())
+                .unwrap()
+        }
+
+        let commit_output = commit_builder.build().await.unwrap();
+
+        let commit_description = alice.process_pending_commit().await.unwrap();
+
+        assert!(!commit_description.is_external);
+
+        assert_eq!(
+            commit_description.committer,
+            alice.group.current_member_index()
+        );
+
+        // Check that applying pending commit and processing commit yields correct update.
+        let state_update_alice = commit_description.state_update.clone();
+
+        assert_eq!(
+            state_update_alice
+                .roster_update
+                .added()
+                .iter()
+                .map(|m| m.index)
+                .collect::<Vec<_>>(),
+            vec![2, 5, 6, 10, 11]
+        );
+
+        assert_eq!(
+            state_update_alice.roster_update.removed(),
+            vec![2, 5, 6]
+                .into_iter()
+                .map(|i| member_from_leaf_node(&leaves[i as usize - 2], LeafIndex(i)))
+                .collect::<Vec<_>>()
+        );
+
+        assert_eq!(
+            state_update_alice
+                .roster_update
+                .updated()
+                .iter()
+                .map(|update| update.new.clone())
+                .collect_vec()
+                .as_slice(),
+            &alice.group.roster().members()[0..2]
+        );
+
+        assert_eq!(
+            state_update_alice.added_psks,
+            (0..5)
+                .map(|i| ExternalPskId::new(vec![i]))
+                .collect::<Vec<_>>()
+        );
+
+        let payload = bob
+            .process_message(commit_output.commit_message)
+            .await
+            .unwrap();
+
+        let ReceivedMessage::Commit(bob_commit_description) = payload else {
+            panic!("expected commit");
+        };
+
+        assert_eq!(commit_description, bob_commit_description);
+    }
+
+    #[cfg(feature = "state_update")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn commit_description_external_commit() {
+        use crate::client::test_utils::TestClientBuilder;
+
+        let mut alice_group = test_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await;
+
+        let (bob_identity, secret_key) = get_test_signing_identity(TEST_CIPHER_SUITE, b"bob").await;
+
+        let bob = TestClientBuilder::new_for_test()
+            .signing_identity(bob_identity, secret_key, TEST_CIPHER_SUITE)
+            .build();
+
+        let (bob_group, commit) = bob
+            .external_commit_builder()
+            .unwrap()
+            .build(
+                alice_group
+                    .group
+                    .group_info_message_allowing_ext_commit(true)
+                    .await
+                    .unwrap(),
+            )
+            .await
+            .unwrap();
+
+        let event = alice_group.process_message(commit).await.unwrap();
+
+        let ReceivedMessage::Commit(commit_description) = event else {
+            panic!("expected commit");
+        };
+
+        assert!(commit_description.is_external);
+        assert_eq!(commit_description.committer, 1);
+
+        assert_eq!(
+            commit_description.state_update.roster_update.added(),
+            &bob_group.roster().members()[1..2]
+        );
+
+        itertools::assert_equal(
+            bob_group.roster().members_iter(),
+            alice_group.group.roster().members_iter(),
+        );
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn can_join_new_group_externally() {
+        use crate::client::test_utils::TestClientBuilder;
+
+        let mut alice_group = test_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await;
+
+        let (bob_identity, secret_key) = get_test_signing_identity(TEST_CIPHER_SUITE, b"bob").await;
+
+        let bob = TestClientBuilder::new_for_test()
+            .signing_identity(bob_identity, secret_key, TEST_CIPHER_SUITE)
+            .build();
+
+        let (_, commit) = bob
+            .external_commit_builder()
+            .unwrap()
+            .with_tree_data(alice_group.group.export_tree().into_owned())
+            .build(
+                alice_group
+                    .group
+                    .group_info_message_allowing_ext_commit(false)
+                    .await
+                    .unwrap(),
+            )
+            .await
+            .unwrap();
+
+        alice_group.process_message(commit).await.unwrap();
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_membership_tag_from_non_member() {
+        let (mut alice_group, mut bob_group) =
+            test_two_member_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, true).await;
+
+        let mut commit_output = alice_group.group.commit(vec![]).await.unwrap();
+
+        let plaintext = match commit_output.commit_message.payload {
+            MlsMessagePayload::Plain(ref mut plain) => plain,
+            _ => panic!("Non plaintext message"),
+        };
+
+        plaintext.content.sender = Sender::NewMemberCommit;
+
+        let res = bob_group
+            .process_message(commit_output.commit_message)
+            .await;
+
+        assert_matches!(res, Err(MlsError::MembershipTagForNonMember));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_partial_commits() {
+        let protocol_version = TEST_PROTOCOL_VERSION;
+        let cipher_suite = TEST_CIPHER_SUITE;
+
+        let mut alice = test_group(protocol_version, cipher_suite).await;
+        let (mut bob, _) = alice.join("bob").await;
+        let (mut charlie, commit) = alice.join("charlie").await;
+        bob.process_message(commit).await.unwrap();
+
+        let (_, commit) = charlie.join("dave").await;
+
+        alice.process_message(commit.clone()).await.unwrap();
+        bob.process_message(commit.clone()).await.unwrap();
+
+        let Content::Commit(commit) = commit.into_plaintext().unwrap().content.content else {
+            panic!("Expected commit")
+        };
+
+        assert!(commit.path.is_none());
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn group_with_path_required() -> TestGroup {
+        let mut alice = test_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await;
+
+        alice.group.config.0.mls_rules.commit_options.path_required = true;
+
+        alice
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn old_hpke_secrets_are_removed() {
+        let mut alice = group_with_path_required().await;
+        alice.join("bob").await;
+        alice.join("charlie").await;
+
+        alice
+            .group
+            .commit_builder()
+            .remove_member(1)
+            .unwrap()
+            .build()
+            .await
+            .unwrap();
+
+        assert!(alice.group.private_tree.secret_keys[1].is_some());
+        alice.process_pending_commit().await.unwrap();
+        assert!(alice.group.private_tree.secret_keys[1].is_none());
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn old_hpke_secrets_of_removed_are_removed() {
+        let mut alice = group_with_path_required().await;
+        alice.join("bob").await;
+        let (mut charlie, _) = alice.join("charlie").await;
+
+        let commit = charlie
+            .group
+            .commit_builder()
+            .remove_member(1)
+            .unwrap()
+            .build()
+            .await
+            .unwrap();
+
+        assert!(alice.group.private_tree.secret_keys[1].is_some());
+        alice.process_message(commit.commit_message).await.unwrap();
+        assert!(alice.group.private_tree.secret_keys[1].is_none());
+    }
+
+    #[cfg(feature = "by_ref_proposal")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn old_hpke_secrets_of_updated_are_removed() {
+        let mut alice = group_with_path_required().await;
+        let (mut bob, _) = alice.join("bob").await;
+        let (mut charlie, commit) = alice.join("charlie").await;
+        bob.process_message(commit).await.unwrap();
+
+        let update = bob.group.propose_update(vec![]).await.unwrap();
+        charlie.process_message(update.clone()).await.unwrap();
+        alice.process_message(update).await.unwrap();
+
+        let commit = charlie.group.commit(vec![]).await.unwrap();
+
+        assert!(alice.group.private_tree.secret_keys[1].is_some());
+        alice.process_message(commit.commit_message).await.unwrap();
+        assert!(alice.group.private_tree.secret_keys[1].is_none());
+    }
+
+    #[cfg(feature = "psk")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn only_selected_members_of_the_original_group_can_join_subgroup() {
+        let mut alice = test_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await;
+        let (mut bob, _) = alice.join("bob").await;
+        let (carol, commit) = alice.join("carol").await;
+
+        // Apply the commit that adds carol
+        bob.group.process_incoming_message(commit).await.unwrap();
+
+        let bob_identity = bob.group.current_member_signing_identity().unwrap().clone();
+        let signer = bob.group.signer.clone();
+
+        let new_key_pkg = Client::new(
+            bob.group.config.clone(),
+            Some(signer),
+            Some((bob_identity, TEST_CIPHER_SUITE)),
+            TEST_PROTOCOL_VERSION,
+        )
+        .generate_key_package_message()
+        .await
+        .unwrap();
+
+        let (mut alice_sub_group, welcome) = alice
+            .group
+            .branch(b"subgroup".to_vec(), vec![new_key_pkg])
+            .await
+            .unwrap();
+
+        let welcome = &welcome[0];
+
+        let (mut bob_sub_group, _) = bob.group.join_subgroup(welcome, None).await.unwrap();
+
+        // Carol can't join
+        let res = carol.group.join_subgroup(welcome, None).await.map(|_| ());
+        assert_matches!(res, Err(_));
+
+        // Alice and Bob can still talk
+        let commit_output = alice_sub_group.commit(vec![]).await.unwrap();
+
+        bob_sub_group
+            .process_incoming_message(commit_output.commit_message)
+            .await
+            .unwrap();
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn joining_group_fails_if_unsupported<F>(
+        f: F,
+    ) -> Result<(TestGroup, MlsMessage), MlsError>
+    where
+        F: FnMut(&mut TestClientConfig),
+    {
+        let mut alice_group = test_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await;
+        alice_group.join_with_custom_config("alice", false, f).await
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn joining_group_fails_if_protocol_version_is_not_supported() {
+        let res = joining_group_fails_if_unsupported(|config| {
+            config.0.settings.protocol_versions.clear();
+        })
+        .await
+        .map(|_| ());
+
+        assert_matches!(
+            res,
+            Err(MlsError::UnsupportedProtocolVersion(v)) if v ==
+                TEST_PROTOCOL_VERSION
+        );
+    }
+
+    // WebCrypto does not support disabling ciphersuites
+    #[cfg(not(target_arch = "wasm32"))]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn joining_group_fails_if_cipher_suite_is_not_supported() {
+        let res = joining_group_fails_if_unsupported(|config| {
+            config
+                .0
+                .crypto_provider
+                .enabled_cipher_suites
+                .retain(|&x| x != TEST_CIPHER_SUITE);
+        })
+        .await
+        .map(|_| ());
+
+        assert_matches!(
+            res,
+            Err(MlsError::UnsupportedCipherSuite(TEST_CIPHER_SUITE))
+        );
+    }
+
+    #[cfg(feature = "private_message")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn member_can_see_sender_creds() {
+        let mut alice_group = test_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await;
+        let (mut bob_group, _) = alice_group.join("bob").await;
+
+        let bob_msg = b"I'm Bob";
+
+        let msg = bob_group
+            .group
+            .encrypt_application_message(bob_msg, vec![])
+            .await
+            .unwrap();
+
+        let received_by_alice = alice_group
+            .group
+            .process_incoming_message(msg)
+            .await
+            .unwrap();
+
+        assert_matches!(
+            received_by_alice,
+            ReceivedMessage::ApplicationMessage(ApplicationMessageDescription { sender_index, .. })
+                if sender_index == bob_group.group.current_member_index()
+        );
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn members_of_a_group_have_identical_authentication_secrets() {
+        let mut alice_group = test_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await;
+        let (bob_group, _) = alice_group.join("bob").await;
+
+        assert_eq!(
+            alice_group.group.epoch_authenticator().unwrap(),
+            bob_group.group.epoch_authenticator().unwrap()
+        );
+    }
+
+    #[cfg(feature = "private_message")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn member_cannot_decrypt_same_message_twice() {
+        let mut alice_group = test_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await;
+        let (mut bob_group, _) = alice_group.join("bob").await;
+
+        let message = alice_group
+            .group
+            .encrypt_application_message(b"foobar", Vec::new())
+            .await
+            .unwrap();
+
+        let received_message = bob_group
+            .group
+            .process_incoming_message(message.clone())
+            .await
+            .unwrap();
+
+        assert_matches!(
+            received_message,
+            ReceivedMessage::ApplicationMessage(m) if m.data() == b"foobar"
+        );
+
+        let res = bob_group.group.process_incoming_message(message).await;
+
+        assert_matches!(res, Err(MlsError::KeyMissing(0)));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn removing_requirements_allows_to_add() {
+        let mut alice_group = test_group_custom(
+            TEST_PROTOCOL_VERSION,
+            TEST_CIPHER_SUITE,
+            vec![17.into()],
+            None,
+            None,
+        )
+        .await;
+
+        alice_group
+            .group
+            .commit_builder()
+            .set_group_context_ext(
+                vec![RequiredCapabilitiesExt {
+                    extensions: vec![17.into()],
+                    ..Default::default()
+                }
+                .into_extension()
+                .unwrap()]
+                .try_into()
+                .unwrap(),
+            )
+            .unwrap()
+            .build()
+            .await
+            .unwrap();
+
+        alice_group.process_pending_commit().await.unwrap();
+
+        let test_key_package =
+            test_key_package(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, "bob").await;
+
+        let test_key_package = MlsMessage::new(
+            TEST_PROTOCOL_VERSION,
+            MlsMessagePayload::KeyPackage(test_key_package),
+        );
+
+        alice_group
+            .group
+            .commit_builder()
+            .add_member(test_key_package)
+            .unwrap()
+            .set_group_context_ext(Default::default())
+            .unwrap()
+            .build()
+            .await
+            .unwrap();
+
+        let state_update = alice_group
+            .process_pending_commit()
+            .await
+            .unwrap()
+            .state_update;
+
+        #[cfg(feature = "state_update")]
+        assert_eq!(
+            state_update
+                .roster_update
+                .added()
+                .iter()
+                .map(|m| m.index)
+                .collect::<Vec<_>>(),
+            vec![1]
+        );
+
+        #[cfg(not(feature = "state_update"))]
+        assert!(state_update == StateUpdate {});
+
+        assert_eq!(alice_group.group.roster().members_iter().count(), 2);
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn commit_leaf_wrong_source() {
+        // RFC, 13.4.2. "The leaf_node_source field MUST be set to commit."
+        let mut groups = test_n_member_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, 3).await;
+
+        groups[0].group.commit_modifiers.modify_leaf = |leaf, sk| {
+            leaf.leaf_node_source = LeafNodeSource::Update;
+            Some(sk.clone())
+        };
+
+        let commit_output = groups[0].group.commit(vec![]).await.unwrap();
+
+        let res = groups[2]
+            .process_message(commit_output.commit_message)
+            .await;
+
+        assert_matches!(res, Err(MlsError::InvalidLeafNodeSource));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn commit_leaf_same_hpke_key() {
+        // RFC 13.4.2. "Verify that the encryption_key value in the LeafNode is different from the committer's current leaf node"
+
+        let mut groups = test_n_member_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, 3).await;
+
+        // Group 0 starts using fixed key
+        groups[0].group.commit_modifiers.modify_leaf = |leaf, sk| {
+            leaf.public_key = get_test_25519_key(1u8);
+            Some(sk.clone())
+        };
+
+        let commit_output = groups[0].group.commit(vec![]).await.unwrap();
+        groups[0].process_pending_commit().await.unwrap();
+        groups[2]
+            .process_message(commit_output.commit_message)
+            .await
+            .unwrap();
+
+        // Group 0 tries to use the fixed key againd
+        let commit_output = groups[0].group.commit(vec![]).await.unwrap();
+
+        let res = groups[2]
+            .process_message(commit_output.commit_message)
+            .await;
+
+        assert_matches!(res, Err(MlsError::SameHpkeKey(0)));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn commit_leaf_duplicate_hpke_key() {
+        // RFC 8.3 "Verify that the following fields are unique among the members of the group: `encryption_key`"
+
+        if TEST_CIPHER_SUITE != CipherSuite::CURVE25519_AES128
+            && TEST_CIPHER_SUITE != CipherSuite::CURVE25519_CHACHA
+        {
+            return;
+        }
+
+        let mut groups = test_n_member_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, 10).await;
+
+        // Group 1 uses the fixed key
+        groups[1].group.commit_modifiers.modify_leaf = |leaf, sk| {
+            leaf.public_key = get_test_25519_key(1u8);
+            Some(sk.clone())
+        };
+
+        let commit_output = groups
+            .get_mut(1)
+            .unwrap()
+            .group
+            .commit(vec![])
+            .await
+            .unwrap();
+
+        process_commit(&mut groups, commit_output.commit_message, 1).await;
+
+        // Group 0 tries to use the fixed key too
+        groups[0].group.commit_modifiers.modify_leaf = |leaf, sk| {
+            leaf.public_key = get_test_25519_key(1u8);
+            Some(sk.clone())
+        };
+
+        let commit_output = groups[0].group.commit(vec![]).await.unwrap();
+
+        let res = groups[7]
+            .process_message(commit_output.commit_message)
+            .await;
+
+        assert_matches!(res, Err(MlsError::DuplicateLeafData(_)));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn commit_leaf_duplicate_signature_key() {
+        // RFC 8.3 "Verify that the following fields are unique among the members of the group: `signature_key`"
+
+        if TEST_CIPHER_SUITE != CipherSuite::CURVE25519_AES128
+            && TEST_CIPHER_SUITE != CipherSuite::CURVE25519_CHACHA
+        {
+            return;
+        }
+
+        let mut groups = test_n_member_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, 10).await;
+
+        // Group 1 uses the fixed key
+        groups[1].group.commit_modifiers.modify_leaf = |leaf, _| {
+            let sk = hex!(
+                "3468b4c890255c983e3d5cbf5cb64c1ef7f6433a518f2f3151d6672f839a06ebcad4fc381fe61822af45135c82921a348e6f46643d66ddefc70483565433714b"
+            )
+            .into();
+
+            leaf.signing_identity.signature_key =
+                hex!("cad4fc381fe61822af45135c82921a348e6f46643d66ddefc70483565433714b").into();
+
+            Some(sk)
+        };
+
+        let commit_output = groups
+            .get_mut(1)
+            .unwrap()
+            .group
+            .commit(vec![])
+            .await
+            .unwrap();
+
+        process_commit(&mut groups, commit_output.commit_message, 1).await;
+
+        // Group 0 tries to use the fixed key too
+        groups[0].group.commit_modifiers.modify_leaf = |leaf, _| {
+            let sk = hex!(
+                "3468b4c890255c983e3d5cbf5cb64c1ef7f6433a518f2f3151d6672f839a06ebcad4fc381fe61822af45135c82921a348e6f46643d66ddefc70483565433714b"
+            )
+            .into();
+
+            leaf.signing_identity.signature_key =
+                hex!("cad4fc381fe61822af45135c82921a348e6f46643d66ddefc70483565433714b").into();
+
+            Some(sk)
+        };
+
+        let commit_output = groups[0].group.commit(vec![]).await.unwrap();
+
+        let res = groups[7]
+            .process_message(commit_output.commit_message)
+            .await;
+
+        assert_matches!(res, Err(MlsError::DuplicateLeafData(_)));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn commit_leaf_incorrect_signature() {
+        let mut groups = test_n_member_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, 3).await;
+
+        groups[0].group.commit_modifiers.modify_leaf = |leaf, _| {
+            leaf.signature[0] ^= 1;
+            None
+        };
+
+        let commit_output = groups[0].group.commit(vec![]).await.unwrap();
+
+        let res = groups[2]
+            .process_message(commit_output.commit_message)
+            .await;
+
+        assert_matches!(res, Err(MlsError::InvalidSignature));
+    }
+
+    #[cfg(not(target_arch = "wasm32"))]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn commit_leaf_not_supporting_used_context_extension() {
+        const EXT_TYPE: ExtensionType = ExtensionType::new(999);
+
+        // The new leaf of the committer doesn't support an extension set in group context
+        let extension = Extension::new(EXT_TYPE, vec![]);
+
+        let mut groups =
+            get_test_groups_with_features(3, vec![extension].into(), Default::default()).await;
+
+        groups[0].commit_modifiers.modify_leaf = |leaf, sk| {
+            leaf.capabilities = get_test_capabilities();
+            Some(sk.clone())
+        };
+
+        let commit_output = groups[0].commit(vec![]).await.unwrap();
+
+        let res = groups[1]
+            .process_incoming_message(commit_output.commit_message)
+            .await;
+
+        assert_matches!(res, Err(MlsError::UnsupportedGroupExtension(EXT_TYPE)));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn commit_leaf_not_supporting_required_extension() {
+        // The new leaf of the committer doesn't support an extension required by group context
+
+        let extension = RequiredCapabilitiesExt {
+            extensions: vec![999.into()],
+            proposals: vec![],
+            credentials: vec![],
+        };
+
+        let extensions = vec![extension.into_extension().unwrap()];
+        let mut groups =
+            get_test_groups_with_features(3, extensions.into(), Default::default()).await;
+
+        groups[0].commit_modifiers.modify_leaf = |leaf, sk| {
+            leaf.capabilities = Capabilities::default();
+            Some(sk.clone())
+        };
+
+        let commit_output = groups[0].commit(vec![]).await.unwrap();
+
+        let res = groups[2]
+            .process_incoming_message(commit_output.commit_message)
+            .await;
+
+        assert!(res.is_err());
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn commit_leaf_has_unsupported_credential() {
+        // The new leaf of the committer has a credential unsupported by another leaf
+        let mut groups =
+            get_test_groups_with_features(3, Default::default(), Default::default()).await;
+
+        for group in groups.iter_mut() {
+            group.config.0.identity_provider.allow_any_custom = true;
+        }
+
+        groups[0].commit_modifiers.modify_leaf = |leaf, sk| {
+            leaf.signing_identity.credential = Credential::Custom(CustomCredential::new(
+                CredentialType::new(43),
+                leaf.signing_identity
+                    .credential
+                    .as_basic()
+                    .unwrap()
+                    .identifier
+                    .to_vec(),
+            ));
+
+            Some(sk.clone())
+        };
+
+        let commit_output = groups[0].commit(vec![]).await.unwrap();
+
+        let res = groups[2]
+            .process_incoming_message(commit_output.commit_message)
+            .await;
+
+        assert_matches!(res, Err(MlsError::CredentialTypeOfNewLeafIsUnsupported));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn commit_leaf_not_supporting_credential_used_in_another_leaf() {
+        // The new leaf of the committer doesn't support another leaf's credential
+
+        let mut groups =
+            get_test_groups_with_features(3, Default::default(), Default::default()).await;
+
+        groups[0].commit_modifiers.modify_leaf = |leaf, sk| {
+            leaf.capabilities.credentials = vec![2.into()];
+            Some(sk.clone())
+        };
+
+        let commit_output = groups[0].commit(vec![]).await.unwrap();
+
+        let res = groups[2]
+            .process_incoming_message(commit_output.commit_message)
+            .await;
+
+        assert_matches!(res, Err(MlsError::InUseCredentialTypeUnsupportedByNewLeaf));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn commit_leaf_not_supporting_required_credential() {
+        // The new leaf of the committer doesn't support a credential required by group context
+
+        let extension = RequiredCapabilitiesExt {
+            extensions: vec![],
+            proposals: vec![],
+            credentials: vec![1.into()],
+        };
+
+        let extensions = vec![extension.into_extension().unwrap()];
+        let mut groups =
+            get_test_groups_with_features(3, extensions.into(), Default::default()).await;
+
+        groups[0].commit_modifiers.modify_leaf = |leaf, sk| {
+            leaf.capabilities.credentials = vec![2.into()];
+            Some(sk.clone())
+        };
+
+        let commit_output = groups[0].commit(vec![]).await.unwrap();
+
+        let res = groups[2]
+            .process_incoming_message(commit_output.commit_message)
+            .await;
+
+        assert_matches!(res, Err(MlsError::RequiredCredentialNotFound(_)));
+    }
+
+    #[cfg(feature = "by_ref_proposal")]
+    #[cfg(not(target_arch = "wasm32"))]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn make_x509_external_senders_ext() -> ExternalSendersExt {
+        let (_, ext_sender_pk) = test_cipher_suite_provider(TEST_CIPHER_SUITE)
+            .signature_key_generate()
+            .await
+            .unwrap();
+
+        let ext_sender_id = SigningIdentity {
+            signature_key: ext_sender_pk,
+            credential: Credential::X509(CertificateChain::from(vec![random_bytes(32)])),
+        };
+
+        ExternalSendersExt::new(vec![ext_sender_id])
+    }
+
+    #[cfg(feature = "by_ref_proposal")]
+    #[cfg(not(target_arch = "wasm32"))]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn commit_leaf_not_supporting_external_sender_credential_leads_to_rejected_commit() {
+        let ext_senders = make_x509_external_senders_ext()
+            .await
+            .into_extension()
+            .unwrap();
+
+        let mut alice = ClientBuilder::new()
+            .crypto_provider(TestCryptoProvider::new())
+            .identity_provider(
+                BasicWithCustomProvider::default().with_credential_type(CredentialType::X509),
+            )
+            .with_random_signing_identity("alice", TEST_CIPHER_SUITE)
+            .await
+            .build()
+            .create_group(core::iter::once(ext_senders).collect())
+            .await
+            .unwrap();
+
+        // New leaf supports only basic credentials (used by the group) but not X509 used by external sender
+        alice.commit_modifiers.modify_leaf = |leaf, sk| {
+            leaf.capabilities.credentials = vec![CredentialType::BASIC];
+            Some(sk.clone())
+        };
+
+        alice.commit(vec![]).await.unwrap();
+        let res = alice.apply_pending_commit().await;
+
+        assert_matches!(
+            res,
+            Err(MlsError::RequiredCredentialNotFound(CredentialType::X509))
+        );
+    }
+
+    #[cfg(feature = "by_ref_proposal")]
+    #[cfg(not(target_arch = "wasm32"))]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn node_not_supporting_external_sender_credential_cannot_join_group() {
+        let ext_senders = make_x509_external_senders_ext()
+            .await
+            .into_extension()
+            .unwrap();
+
+        let mut alice = ClientBuilder::new()
+            .crypto_provider(TestCryptoProvider::new())
+            .identity_provider(
+                BasicWithCustomProvider::default().with_credential_type(CredentialType::X509),
+            )
+            .with_random_signing_identity("alice", TEST_CIPHER_SUITE)
+            .await
+            .build()
+            .create_group(core::iter::once(ext_senders).collect())
+            .await
+            .unwrap();
+
+        let (_, bob_key_pkg) =
+            test_client_with_key_pkg(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, "bob").await;
+
+        let commit = alice
+            .commit_builder()
+            .add_member(bob_key_pkg)
+            .unwrap()
+            .build()
+            .await;
+
+        assert_matches!(
+            commit,
+            Err(MlsError::RequiredCredentialNotFound(CredentialType::X509))
+        );
+    }
+
+    #[cfg(feature = "by_ref_proposal")]
+    #[cfg(not(target_arch = "wasm32"))]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn external_senders_extension_is_rejected_if_member_does_not_support_credential_type() {
+        let mut alice = ClientBuilder::new()
+            .crypto_provider(TestCryptoProvider::new())
+            .identity_provider(
+                BasicWithCustomProvider::default().with_credential_type(CredentialType::X509),
+            )
+            .with_random_signing_identity("alice", TEST_CIPHER_SUITE)
+            .await
+            .build()
+            .create_group(Default::default())
+            .await
+            .unwrap();
+
+        let (_, bob_key_pkg) =
+            test_client_with_key_pkg(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, "bob").await;
+
+        alice
+            .commit_builder()
+            .add_member(bob_key_pkg)
+            .unwrap()
+            .build()
+            .await
+            .unwrap();
+
+        alice.apply_pending_commit().await.unwrap();
+        assert_eq!(alice.roster().members_iter().count(), 2);
+
+        let ext_senders = make_x509_external_senders_ext()
+            .await
+            .into_extension()
+            .unwrap();
+
+        let res = alice
+            .commit_builder()
+            .set_group_context_ext(core::iter::once(ext_senders).collect())
+            .unwrap()
+            .build()
+            .await;
+
+        assert_matches!(
+            res,
+            Err(MlsError::RequiredCredentialNotFound(CredentialType::X509))
+        );
+    }
+
+    /*
+     * Edge case paths
+     */
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn committing_degenerate_path_succeeds() {
+        let mut groups = test_n_member_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, 10).await;
+
+        groups[0].group.commit_modifiers.modify_tree = |tree: &mut TreeKemPublic| {
+            tree.update_node(get_test_25519_key(1u8), 1).unwrap();
+            tree.update_node(get_test_25519_key(1u8), 3).unwrap();
+        };
+
+        groups[0].group.commit_modifiers.modify_leaf = |leaf, sk| {
+            leaf.public_key = get_test_25519_key(1u8);
+            Some(sk.clone())
+        };
+
+        let commit_output = groups[0].group.commit(vec![]).await.unwrap();
+
+        let res = groups[7]
+            .process_message(commit_output.commit_message)
+            .await;
+
+        assert!(res.is_ok());
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn inserting_key_in_filtered_node_fails() {
+        let mut groups = test_n_member_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, 10).await;
+
+        let commit_output = groups[0]
+            .group
+            .commit_builder()
+            .remove_member(1)
+            .unwrap()
+            .build()
+            .await
+            .unwrap();
+
+        groups[0].process_pending_commit().await.unwrap();
+
+        for group in groups.iter_mut().skip(2) {
+            group
+                .process_message(commit_output.commit_message.clone())
+                .await
+                .unwrap();
+        }
+
+        groups[0].group.commit_modifiers.modify_tree = |tree: &mut TreeKemPublic| {
+            tree.update_node(get_test_25519_key(1u8), 1).unwrap();
+        };
+
+        groups[0].group.commit_modifiers.modify_path = |path: Vec<UpdatePathNode>| {
+            let mut path = path;
+            let mut node = path[0].clone();
+            node.public_key = get_test_25519_key(1u8);
+            path.insert(0, node);
+            path
+        };
+
+        let commit_output = groups[0].group.commit(vec![]).await.unwrap();
+
+        let res = groups[7]
+            .process_message(commit_output.commit_message)
+            .await;
+
+        // We should get a path validation error, since the path is too long
+        assert_matches!(res, Err(MlsError::WrongPathLen));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn commit_with_too_short_path_fails() {
+        let mut groups = test_n_member_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, 10).await;
+
+        let commit_output = groups[0]
+            .group
+            .commit_builder()
+            .remove_member(1)
+            .unwrap()
+            .build()
+            .await
+            .unwrap();
+
+        groups[0].process_pending_commit().await.unwrap();
+
+        for group in groups.iter_mut().skip(2) {
+            group
+                .process_message(commit_output.commit_message.clone())
+                .await
+                .unwrap();
+        }
+
+        groups[0].group.commit_modifiers.modify_path = |path: Vec<UpdatePathNode>| {
+            let mut path = path;
+            path.pop();
+            path
+        };
+
+        let commit_output = groups[0].group.commit(vec![]).await.unwrap();
+
+        let res = groups[7]
+            .process_message(commit_output.commit_message)
+            .await;
+
+        assert!(res.is_err());
+    }
+
+    #[cfg(feature = "by_ref_proposal")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn update_proposal_can_change_credential() {
+        let mut groups = test_n_member_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, 3).await;
+        let (identity, secret_key) = get_test_signing_identity(TEST_CIPHER_SUITE, b"member").await;
+
+        let update = groups[0]
+            .group
+            .propose_update_with_identity(secret_key, identity.clone(), vec![])
+            .await
+            .unwrap();
+
+        groups[1].process_message(update).await.unwrap();
+        let commit_output = groups[1].group.commit(vec![]).await.unwrap();
+
+        // Check that the credential was updated by in the committer's state.
+        groups[1].process_pending_commit().await.unwrap();
+        let new_member = groups[1].group.roster().member_with_index(0).unwrap();
+
+        assert_eq!(
+            new_member.signing_identity.credential,
+            get_test_basic_credential(b"member".to_vec())
+        );
+
+        assert_eq!(
+            new_member.signing_identity.signature_key,
+            identity.signature_key
+        );
+
+        // Check that the credential was updated in the updater's state.
+        groups[0]
+            .process_message(commit_output.commit_message)
+            .await
+            .unwrap();
+        let new_member = groups[0].group.roster().member_with_index(0).unwrap();
+
+        assert_eq!(
+            new_member.signing_identity.credential,
+            get_test_basic_credential(b"member".to_vec())
+        );
+
+        assert_eq!(
+            new_member.signing_identity.signature_key,
+            identity.signature_key
+        );
+    }
+
+    #[cfg(feature = "by_ref_proposal")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn receiving_commit_with_old_adds_fails() {
+        let mut groups = test_n_member_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, 2).await;
+
+        let key_package =
+            test_key_package_message(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, "foobar").await;
+
+        let proposal = groups[0]
+            .group
+            .propose_add(key_package, vec![])
+            .await
+            .unwrap();
+
+        let commit = groups[0].group.commit(vec![]).await.unwrap().commit_message;
+
+        // 10 years from now
+        let future_time = MlsTime::now().seconds_since_epoch() + 10 * 365 * 24 * 3600;
+
+        let future_time =
+            MlsTime::from_duration_since_epoch(core::time::Duration::from_secs(future_time));
+
+        groups[1]
+            .group
+            .process_incoming_message(proposal)
+            .await
+            .unwrap();
+        let res = groups[1]
+            .group
+            .process_incoming_message_with_time(commit, future_time)
+            .await;
+
+        assert_matches!(res, Err(MlsError::InvalidLifetime));
+    }
+
+    #[cfg(feature = "custom_proposal")]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn custom_proposal_setup() -> (TestGroup, TestGroup) {
+        let mut alice = test_group_custom_config(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, |b| {
+            b.custom_proposal_type(TEST_CUSTOM_PROPOSAL_TYPE)
+        })
+        .await;
+
+        let (bob, _) = alice
+            .join_with_custom_config("bob", true, |c| {
+                c.0.settings
+                    .custom_proposal_types
+                    .push(TEST_CUSTOM_PROPOSAL_TYPE)
+            })
+            .await
+            .unwrap();
+
+        (alice, bob)
+    }
+
+    #[cfg(feature = "custom_proposal")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn custom_proposal_by_value() {
+        let (mut alice, mut bob) = custom_proposal_setup().await;
+
+        let custom_proposal = CustomProposal::new(TEST_CUSTOM_PROPOSAL_TYPE, vec![0, 1, 2]);
+
+        let commit = alice
+            .group
+            .commit_builder()
+            .custom_proposal(custom_proposal.clone())
+            .build()
+            .await
+            .unwrap()
+            .commit_message;
+
+        let res = bob.group.process_incoming_message(commit).await.unwrap();
+
+        #[cfg(feature = "state_update")]
+        assert_matches!(res, ReceivedMessage::Commit(CommitMessageDescription { state_update: StateUpdate { custom_proposals, .. }, .. })
+            if custom_proposals.len() == 1 && custom_proposals[0].proposal == custom_proposal);
+
+        #[cfg(not(feature = "state_update"))]
+        assert_matches!(res, ReceivedMessage::Commit(_));
+    }
+
+    #[cfg(feature = "custom_proposal")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn custom_proposal_by_reference() {
+        let (mut alice, mut bob) = custom_proposal_setup().await;
+
+        let custom_proposal = CustomProposal::new(TEST_CUSTOM_PROPOSAL_TYPE, vec![0, 1, 2]);
+
+        let proposal = alice
+            .group
+            .propose_custom(custom_proposal.clone(), vec![])
+            .await
+            .unwrap();
+
+        let recv_prop = bob.group.process_incoming_message(proposal).await.unwrap();
+
+        assert_matches!(recv_prop, ReceivedMessage::Proposal(ProposalMessageDescription { proposal: Proposal::Custom(c), ..})
+            if c == custom_proposal);
+
+        let commit = bob.group.commit(vec![]).await.unwrap().commit_message;
+        let res = alice.group.process_incoming_message(commit).await.unwrap();
+
+        #[cfg(feature = "state_update")]
+        assert_matches!(res, ReceivedMessage::Commit(CommitMessageDescription { state_update: StateUpdate { custom_proposals, .. }, .. })
+            if custom_proposals.len() == 1 && custom_proposals[0].proposal == custom_proposal);
+
+        #[cfg(not(feature = "state_update"))]
+        assert_matches!(res, ReceivedMessage::Commit(_));
+    }
+
+    #[cfg(feature = "psk")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn can_join_with_psk() {
+        let mut alice = test_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE)
+            .await
+            .group;
+
+        let (bob, key_pkg) =
+            test_client_with_key_pkg(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, "bob").await;
+
+        let psk_id = ExternalPskId::new(vec![0]);
+        let psk = PreSharedKey::from(vec![0]);
+
+        alice
+            .config
+            .secret_store()
+            .insert(psk_id.clone(), psk.clone());
+
+        bob.config.secret_store().insert(psk_id.clone(), psk);
+
+        let commit = alice
+            .commit_builder()
+            .add_member(key_pkg)
+            .unwrap()
+            .add_external_psk(psk_id)
+            .unwrap()
+            .build()
+            .await
+            .unwrap();
+
+        bob.join_group(None, &commit.welcome_messages[0])
+            .await
+            .unwrap();
+    }
+
+    #[cfg(feature = "by_ref_proposal")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn invalid_update_does_not_prevent_other_updates() {
+        const EXTENSION_TYPE: ExtensionType = ExtensionType::new(33);
+
+        let group_extensions = ExtensionList::from(vec![RequiredCapabilitiesExt {
+            extensions: vec![EXTENSION_TYPE],
+            ..Default::default()
+        }
+        .into_extension()
+        .unwrap()]);
+
+        // Alice creates a group requiring support for an extension
+        let mut alice = TestClientBuilder::new_for_test()
+            .with_random_signing_identity("alice", TEST_CIPHER_SUITE)
+            .await
+            .extension_type(EXTENSION_TYPE)
+            .build()
+            .create_group(group_extensions.clone())
+            .await
+            .unwrap();
+
+        let (bob_signing_identity, bob_secret_key) =
+            get_test_signing_identity(TEST_CIPHER_SUITE, b"bob").await;
+
+        let bob_client = TestClientBuilder::new_for_test()
+            .signing_identity(
+                bob_signing_identity.clone(),
+                bob_secret_key.clone(),
+                TEST_CIPHER_SUITE,
+            )
+            .extension_type(EXTENSION_TYPE)
+            .build();
+
+        let carol_client = TestClientBuilder::new_for_test()
+            .with_random_signing_identity("carol", TEST_CIPHER_SUITE)
+            .await
+            .extension_type(EXTENSION_TYPE)
+            .build();
+
+        let dave_client = TestClientBuilder::new_for_test()
+            .with_random_signing_identity("dave", TEST_CIPHER_SUITE)
+            .await
+            .extension_type(EXTENSION_TYPE)
+            .build();
+
+        // Alice adds Bob, Carol and Dave to the group. They all support the mandatory extension.
+        let commit = alice
+            .commit_builder()
+            .add_member(bob_client.generate_key_package_message().await.unwrap())
+            .unwrap()
+            .add_member(carol_client.generate_key_package_message().await.unwrap())
+            .unwrap()
+            .add_member(dave_client.generate_key_package_message().await.unwrap())
+            .unwrap()
+            .build()
+            .await
+            .unwrap();
+
+        alice.apply_pending_commit().await.unwrap();
+
+        let mut bob = bob_client
+            .join_group(None, &commit.welcome_messages[0])
+            .await
+            .unwrap()
+            .0;
+
+        bob.write_to_storage().await.unwrap();
+
+        // Bob reloads his group data, but with parameters that will cause his generated leaves to
+        // not support the mandatory extension.
+        let mut bob = TestClientBuilder::new_for_test()
+            .signing_identity(bob_signing_identity, bob_secret_key, TEST_CIPHER_SUITE)
+            .key_package_repo(bob.config.key_package_repo())
+            .group_state_storage(bob.config.group_state_storage())
+            .build()
+            .load_group(alice.group_id())
+            .await
+            .unwrap();
+
+        let mut carol = carol_client
+            .join_group(None, &commit.welcome_messages[0])
+            .await
+            .unwrap()
+            .0;
+
+        let mut dave = dave_client
+            .join_group(None, &commit.welcome_messages[0])
+            .await
+            .unwrap()
+            .0;
+
+        // Bob's updated leaf does not support the mandatory extension.
+        let bob_update = bob.propose_update(Vec::new()).await.unwrap();
+        let carol_update = carol.propose_update(Vec::new()).await.unwrap();
+        let dave_update = dave.propose_update(Vec::new()).await.unwrap();
+
+        // Alice receives the update proposals to be committed.
+        alice.process_incoming_message(bob_update).await.unwrap();
+        alice.process_incoming_message(carol_update).await.unwrap();
+        alice.process_incoming_message(dave_update).await.unwrap();
+
+        // Alice commits the update proposals.
+        alice.commit(Vec::new()).await.unwrap();
+        let commit_desc = alice.apply_pending_commit().await.unwrap();
+
+        let find_update_for = |id: &str| {
+            commit_desc
+                .state_update
+                .roster_update
+                .updated()
+                .iter()
+                .filter_map(|u| u.prior.signing_identity.credential.as_basic())
+                .any(|c| c.identifier == id.as_bytes())
+        };
+
+        // Check that all updates preserve identities.
+        let identities_are_preserved = commit_desc
+            .state_update
+            .roster_update
+            .updated()
+            .iter()
+            .filter_map(|u| {
+                let before = &u.prior.signing_identity.credential.as_basic()?.identifier;
+                let after = &u.new.signing_identity.credential.as_basic()?.identifier;
+                Some((before, after))
+            })
+            .all(|(before, after)| before == after);
+
+        assert!(identities_are_preserved);
+
+        // Carol's and Dave's updates should be part of the commit.
+        assert!(find_update_for("carol"));
+        assert!(find_update_for("dave"));
+
+        // Bob's update should be rejected.
+        assert!(!find_update_for("bob"));
+
+        // Check that all members are still in the group.
+        let all_members_are_in = alice
+            .roster()
+            .members_iter()
+            .zip(["alice", "bob", "carol", "dave"])
+            .all(|(member, id)| {
+                member
+                    .signing_identity
+                    .credential
+                    .as_basic()
+                    .unwrap()
+                    .identifier
+                    == id.as_bytes()
+            });
+
+        assert!(all_members_are_in);
+    }
+
+    #[cfg(feature = "custom_proposal")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn custom_proposal_may_enforce_path() {
+        test_custom_proposal_mls_rules(true).await;
+    }
+
+    #[cfg(feature = "custom_proposal")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn custom_proposal_need_not_enforce_path() {
+        test_custom_proposal_mls_rules(false).await;
+    }
+
+    #[cfg(feature = "custom_proposal")]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn test_custom_proposal_mls_rules(path_required_for_custom: bool) {
+        let mls_rules = CustomMlsRules {
+            path_required_for_custom,
+            external_joiner_can_send_custom: true,
+        };
+
+        let mut alice = client_with_custom_rules(b"alice", mls_rules.clone())
+            .await
+            .create_group(Default::default())
+            .await
+            .unwrap();
+
+        let alice_pub_before = alice.current_user_leaf_node().unwrap().public_key.clone();
+
+        let kp = client_with_custom_rules(b"bob", mls_rules)
+            .await
+            .generate_key_package_message()
+            .await
+            .unwrap();
+
+        alice
+            .commit_builder()
+            .custom_proposal(CustomProposal::new(TEST_CUSTOM_PROPOSAL_TYPE, vec![]))
+            .add_member(kp)
+            .unwrap()
+            .build()
+            .await
+            .unwrap();
+
+        alice.apply_pending_commit().await.unwrap();
+
+        let alice_pub_after = &alice.current_user_leaf_node().unwrap().public_key;
+
+        if path_required_for_custom {
+            assert_ne!(alice_pub_after, &alice_pub_before);
+        } else {
+            assert_eq!(alice_pub_after, &alice_pub_before);
+        }
+    }
+
+    #[cfg(feature = "custom_proposal")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn custom_proposal_by_value_in_external_join_may_be_allowed() {
+        test_custom_proposal_by_value_in_external_join(true).await
+    }
+
+    #[cfg(feature = "custom_proposal")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn custom_proposal_by_value_in_external_join_may_not_be_allowed() {
+        test_custom_proposal_by_value_in_external_join(false).await
+    }
+
+    #[cfg(feature = "custom_proposal")]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn test_custom_proposal_by_value_in_external_join(external_joiner_can_send_custom: bool) {
+        let mls_rules = CustomMlsRules {
+            path_required_for_custom: true,
+            external_joiner_can_send_custom,
+        };
+
+        let mut alice = client_with_custom_rules(b"alice", mls_rules.clone())
+            .await
+            .create_group(Default::default())
+            .await
+            .unwrap();
+
+        let group_info = alice
+            .group_info_message_allowing_ext_commit(true)
+            .await
+            .unwrap();
+
+        let commit = client_with_custom_rules(b"bob", mls_rules)
+            .await
+            .external_commit_builder()
+            .unwrap()
+            .with_custom_proposal(CustomProposal::new(TEST_CUSTOM_PROPOSAL_TYPE, vec![]))
+            .build(group_info)
+            .await;
+
+        if external_joiner_can_send_custom {
+            let commit = commit.unwrap().1;
+            alice.process_incoming_message(commit).await.unwrap();
+        } else {
+            assert_matches!(commit.map(|_| ()), Err(MlsError::MlsRulesError(_)));
+        }
+    }
+
+    #[cfg(feature = "custom_proposal")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn custom_proposal_by_ref_in_external_join() {
+        let mls_rules = CustomMlsRules {
+            path_required_for_custom: true,
+            external_joiner_can_send_custom: true,
+        };
+
+        let mut alice = client_with_custom_rules(b"alice", mls_rules.clone())
+            .await
+            .create_group(Default::default())
+            .await
+            .unwrap();
+
+        let by_ref = CustomProposal::new(TEST_CUSTOM_PROPOSAL_TYPE, vec![]);
+        let by_ref = alice.propose_custom(by_ref, vec![]).await.unwrap();
+
+        let group_info = alice
+            .group_info_message_allowing_ext_commit(true)
+            .await
+            .unwrap();
+
+        let (_, commit) = client_with_custom_rules(b"bob", mls_rules)
+            .await
+            .external_commit_builder()
+            .unwrap()
+            .with_received_custom_proposal(by_ref)
+            .build(group_info)
+            .await
+            .unwrap();
+
+        alice.process_incoming_message(commit).await.unwrap();
+    }
+
+    #[cfg(feature = "custom_proposal")]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn client_with_custom_rules(
+        name: &[u8],
+        mls_rules: CustomMlsRules,
+    ) -> Client<impl MlsConfig> {
+        let (signing_identity, signer) = get_test_signing_identity(TEST_CIPHER_SUITE, name).await;
+
+        ClientBuilder::new()
+            .crypto_provider(TestCryptoProvider::new())
+            .identity_provider(BasicWithCustomProvider::new(BasicIdentityProvider::new()))
+            .signing_identity(signing_identity, signer, TEST_CIPHER_SUITE)
+            .custom_proposal_type(TEST_CUSTOM_PROPOSAL_TYPE)
+            .mls_rules(mls_rules)
+            .build()
+    }
+
+    #[derive(Debug, Clone)]
+    struct CustomMlsRules {
+        path_required_for_custom: bool,
+        external_joiner_can_send_custom: bool,
+    }
+
+    #[cfg(feature = "custom_proposal")]
+    impl ProposalBundle {
+        fn has_test_custom_proposal(&self) -> bool {
+            self.custom_proposal_types()
+                .any(|t| t == TEST_CUSTOM_PROPOSAL_TYPE)
+        }
+    }
+
+    #[cfg(feature = "custom_proposal")]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    #[cfg_attr(mls_build_async, maybe_async::must_be_async)]
+    impl crate::MlsRules for CustomMlsRules {
+        type Error = MlsError;
+
+        fn commit_options(
+            &self,
+            _: &Roster,
+            _: &ExtensionList,
+            proposals: &ProposalBundle,
+        ) -> Result<CommitOptions, MlsError> {
+            Ok(CommitOptions::default().with_path_required(
+                !proposals.has_test_custom_proposal() || self.path_required_for_custom,
+            ))
+        }
+
+        fn encryption_options(
+            &self,
+            _: &Roster,
+            _: &ExtensionList,
+        ) -> Result<crate::mls_rules::EncryptionOptions, MlsError> {
+            Ok(Default::default())
+        }
+
+        async fn filter_proposals(
+            &self,
+            _: CommitDirection,
+            sender: CommitSource,
+            _: &Roster,
+            _: &ExtensionList,
+            proposals: ProposalBundle,
+        ) -> Result<ProposalBundle, MlsError> {
+            let is_external = matches!(sender, CommitSource::NewMember(_));
+            let has_custom = proposals.has_test_custom_proposal();
+            let allowed = !has_custom || !is_external || self.external_joiner_can_send_custom;
+
+            allowed.then_some(proposals).ok_or(MlsError::InvalidSender)
+        }
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn group_can_receive_commit_from_self() {
+        let mut group = test_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE)
+            .await
+            .group;
+
+        let commit = group.commit(vec![]).await.unwrap();
+
+        let update = group
+            .process_incoming_message(commit.commit_message)
+            .await
+            .unwrap();
+
+        let ReceivedMessage::Commit(update) = update else {
+            panic!("expected commit message")
+        };
+
+        assert_eq!(update.committer, *group.private_tree.self_index);
+    }
+}
diff --git a/src/group/padding.rs b/src/group/padding.rs
new file mode 100644
index 0000000..6320ccf
--- /dev/null
+++ b/src/group/padding.rs
@@ -0,0 +1,109 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// Copyright by contributors to this project.
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+
+/// Padding used when sending an encrypted group message.
+#[cfg_attr(all(feature = "ffi", not(test)), safer_ffi_gen::ffi_type)]
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+#[repr(u8)]
+pub enum PaddingMode {
+    /// Step function based on the size of the message being sent.
+    /// The amount of padding used will increase with the size of the original
+    /// message.
+    #[default]
+    StepFunction,
+    /// No padding.
+    None,
+}
+
+impl PaddingMode {
+    pub(super) fn padded_size(&self, content_size: usize) -> usize {
+        match self {
+            PaddingMode::StepFunction => {
+                // The padding hides all but 2 most significant bits of `length`. The hidden bits are replaced
+                // by zeros and then the next number is taken to make sure the message fits.
+                let blind = 1
+                    << ((content_size + 1)
+                        .next_power_of_two()
+                        .max(256)
+                        .trailing_zeros()
+                        - 3);
+
+                (content_size | (blind - 1)) + 1
+            }
+            PaddingMode::None => content_size,
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::PaddingMode;
+
+    use alloc::vec;
+    use alloc::vec::Vec;
+    #[cfg(target_arch = "wasm32")]
+    use wasm_bindgen_test::wasm_bindgen_test as test;
+
+    #[derive(serde::Deserialize, serde::Serialize)]
+    struct TestCase {
+        input: usize,
+        output: usize,
+    }
+
+    #[cfg_attr(coverage_nightly, coverage(off))]
+    fn generate_message_padding_test_vector() -> Vec<TestCase> {
+        let mut test_cases = vec![];
+        for x in 1..1024 {
+            test_cases.push(TestCase {
+                input: x,
+                output: PaddingMode::StepFunction.padded_size(x),
+            });
+        }
+        test_cases
+    }
+
+    fn load_test_cases() -> Vec<TestCase> {
+        load_test_case_json!(
+            message_padding_test_vector,
+            generate_message_padding_test_vector()
+        )
+    }
+
+    #[test]
+    fn test_no_padding() {
+        for i in [0, 100, 1000, 10000] {
+            assert_eq!(PaddingMode::None.padded_size(i), i)
+        }
+    }
+
+    #[test]
+    fn test_padding_length() {
+        assert_eq!(PaddingMode::StepFunction.padded_size(0), 32);
+
+        // Short
+        assert_eq!(PaddingMode::StepFunction.padded_size(63), 64);
+        assert_eq!(PaddingMode::StepFunction.padded_size(64), 96);
+        assert_eq!(PaddingMode::StepFunction.padded_size(65), 96);
+
+        // Almost long and almost short
+        assert_eq!(PaddingMode::StepFunction.padded_size(127), 128);
+        assert_eq!(PaddingMode::StepFunction.padded_size(128), 160);
+        assert_eq!(PaddingMode::StepFunction.padded_size(129), 160);
+
+        // One length from each of the 4 buckets between 256 and 512
+        assert_eq!(PaddingMode::StepFunction.padded_size(260), 320);
+        assert_eq!(PaddingMode::StepFunction.padded_size(330), 384);
+        assert_eq!(PaddingMode::StepFunction.padded_size(390), 448);
+        assert_eq!(PaddingMode::StepFunction.padded_size(490), 512);
+
+        // All test cases
+        let test_cases: Vec<TestCase> = load_test_cases();
+        for test_case in test_cases {
+            assert_eq!(
+                test_case.output,
+                PaddingMode::StepFunction.padded_size(test_case.input)
+            );
+        }
+    }
+}
diff --git a/src/group/proposal.rs b/src/group/proposal.rs
new file mode 100644
index 0000000..a31be29
--- /dev/null
+++ b/src/group/proposal.rs
@@ -0,0 +1,578 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// Copyright by contributors to this project.
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+
+use alloc::{boxed::Box, vec::Vec};
+
+#[cfg(feature = "by_ref_proposal")]
+use crate::tree_kem::leaf_node::LeafNode;
+
+use crate::{
+    client::MlsError, tree_kem::node::LeafIndex, CipherSuite, KeyPackage, MlsMessage,
+    ProtocolVersion,
+};
+use core::fmt::{self, Debug};
+use mls_rs_codec::{MlsDecode, MlsEncode, MlsSize};
+use mls_rs_core::{group::Capabilities, identity::SigningIdentity};
+
+#[cfg(feature = "by_ref_proposal")]
+use crate::group::proposal_ref::ProposalRef;
+
+pub use mls_rs_core::extension::ExtensionList;
+pub use mls_rs_core::group::ProposalType;
+
+#[cfg(feature = "psk")]
+use crate::psk::{ExternalPskId, JustPreSharedKeyID, PreSharedKeyID};
+
+#[derive(Clone, Debug, PartialEq, MlsSize, MlsEncode, MlsDecode)]
+#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+/// A proposal that adds a member to a [`Group`](crate::group::Group).
+pub struct AddProposal {
+    pub(crate) key_package: KeyPackage,
+}
+
+impl AddProposal {
+    /// The [`SigningIdentity`] of the [`Member`](mls_rs_core::group::Member)
+    /// that will be added by this proposal.
+    pub fn signing_identity(&self) -> &SigningIdentity {
+        self.key_package.signing_identity()
+    }
+
+    /// Client [`Capabilities`] of the [`Member`](mls_rs_core::group::Member)
+    /// that will be added by this proposal.
+    pub fn capabilities(&self) -> Capabilities {
+        self.key_package.leaf_node.ungreased_capabilities()
+    }
+
+    /// Key package extensions that are assoiciated with the
+    /// [`Member`](mls_rs_core::group::Member) that will be added by this proposal.
+    pub fn key_package_extensions(&self) -> ExtensionList {
+        self.key_package.ungreased_extensions()
+    }
+
+    /// Leaf node extensions that will be entered into the group state for the
+    /// [`Member`](mls_rs_core::group::Member) that will be added.
+    pub fn leaf_node_extensions(&self) -> ExtensionList {
+        self.key_package.leaf_node.ungreased_extensions()
+    }
+}
+
+impl From<KeyPackage> for AddProposal {
+    fn from(key_package: KeyPackage) -> Self {
+        Self { key_package }
+    }
+}
+
+impl TryFrom<MlsMessage> for AddProposal {
+    type Error = MlsError;
+
+    fn try_from(value: MlsMessage) -> Result<Self, Self::Error> {
+        value
+            .into_key_package()
+            .ok_or(MlsError::UnexpectedMessageType)
+            .map(Into::into)
+    }
+}
+
+#[cfg(feature = "by_ref_proposal")]
+#[derive(Clone, Debug, PartialEq, MlsSize, MlsEncode, MlsDecode)]
+#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+/// A proposal that will update an existing [`Member`](mls_rs_core::group::Member) of a
+/// [`Group`](crate::group::Group).
+pub struct UpdateProposal {
+    pub(crate) leaf_node: LeafNode,
+}
+
+#[cfg(feature = "by_ref_proposal")]
+impl UpdateProposal {
+    /// The new [`SigningIdentity`] of the [`Member`](mls_rs_core::group::Member)
+    /// that is being updated by this proposal.
+    pub fn signing_identity(&self) -> &SigningIdentity {
+        &self.leaf_node.signing_identity
+    }
+
+    /// New Client [`Capabilities`] of the [`Member`](mls_rs_core::group::Member)
+    /// that will be updated by this proposal.
+    pub fn capabilities(&self) -> Capabilities {
+        self.leaf_node.ungreased_capabilities()
+    }
+
+    /// New Leaf node extensions that will be entered into the group state for the
+    /// [`Member`](mls_rs_core::group::Member) that is being updated by this proposal.
+    pub fn leaf_node_extensions(&self) -> ExtensionList {
+        self.leaf_node.ungreased_extensions()
+    }
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, MlsSize, MlsEncode, MlsDecode)]
+#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+/// A proposal to remove an existing [`Member`](mls_rs_core::group::Member) of a
+/// [`Group`](crate::group::Group).
+pub struct RemoveProposal {
+    pub(crate) to_remove: LeafIndex,
+}
+
+impl RemoveProposal {
+    /// The index of the [`Member`](mls_rs_core::group::Member) that will be removed by
+    /// this proposal.
+    pub fn to_remove(&self) -> u32 {
+        *self.to_remove
+    }
+}
+
+impl From<u32> for RemoveProposal {
+    fn from(value: u32) -> Self {
+        RemoveProposal {
+            to_remove: LeafIndex(value),
+        }
+    }
+}
+
+#[cfg(feature = "psk")]
+#[derive(Clone, Debug, PartialEq, Eq, MlsSize, MlsEncode, MlsDecode)]
+#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+/// A proposal to add a pre-shared key to a group.
+pub struct PreSharedKeyProposal {
+    pub(crate) psk: PreSharedKeyID,
+}
+
+#[cfg(feature = "psk")]
+impl PreSharedKeyProposal {
+    /// The external pre-shared key id of this proposal.
+    ///
+    /// MLS requires the pre-shared key type for PreSharedKeyProposal to be of
+    /// type `External`.
+    ///
+    /// Returns `None` in the condition that the underlying psk is not external.
+    pub fn external_psk_id(&self) -> Option<&ExternalPskId> {
+        match self.psk.key_id {
+            JustPreSharedKeyID::External(ref ext) => Some(ext),
+            JustPreSharedKeyID::Resumption(_) => None,
+        }
+    }
+}
+
+#[derive(Clone, PartialEq, MlsSize, MlsEncode, MlsDecode)]
+#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+/// A proposal to reinitialize a group using new parameters.
+pub struct ReInitProposal {
+    #[mls_codec(with = "mls_rs_codec::byte_vec")]
+    #[cfg_attr(feature = "serde", serde(with = "mls_rs_core::vec_serde"))]
+    pub(crate) group_id: Vec<u8>,
+    pub(crate) version: ProtocolVersion,
+    pub(crate) cipher_suite: CipherSuite,
+    pub(crate) extensions: ExtensionList,
+}
+
+impl Debug for ReInitProposal {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.debug_struct("ReInitProposal")
+            .field(
+                "group_id",
+                &mls_rs_core::debug::pretty_group_id(&self.group_id),
+            )
+            .field("version", &self.version)
+            .field("cipher_suite", &self.cipher_suite)
+            .field("extensions", &self.extensions)
+            .finish()
+    }
+}
+
+impl ReInitProposal {
+    /// The unique id of the new group post reinitialization.
+    pub fn group_id(&self) -> &[u8] {
+        &self.group_id
+    }
+
+    /// The new protocol version to use post reinitialization.
+    pub fn new_version(&self) -> ProtocolVersion {
+        self.version
+    }
+
+    /// The new ciphersuite to use post reinitialization.
+    pub fn new_cipher_suite(&self) -> CipherSuite {
+        self.cipher_suite
+    }
+
+    /// Group context extensions to set in the new group post reinitialization.
+    pub fn new_group_context_extensions(&self) -> &ExtensionList {
+        &self.extensions
+    }
+}
+
+#[derive(Clone, PartialEq, Eq, MlsSize, MlsEncode, MlsDecode)]
+#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+/// A proposal used for external commits.
+pub struct ExternalInit {
+    #[mls_codec(with = "mls_rs_codec::byte_vec")]
+    #[cfg_attr(feature = "serde", serde(with = "mls_rs_core::vec_serde"))]
+    pub(crate) kem_output: Vec<u8>,
+}
+
+impl Debug for ExternalInit {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.debug_struct("ExternalInit")
+            .field(
+                "kem_output",
+                &mls_rs_core::debug::pretty_bytes(&self.kem_output),
+            )
+            .finish()
+    }
+}
+
+#[cfg(feature = "custom_proposal")]
+#[derive(Clone, PartialEq, Eq)]
+#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
+#[cfg_attr(
+    all(feature = "ffi", not(test)),
+    safer_ffi_gen::ffi_type(clone, opaque)
+)]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+/// A user defined custom proposal.
+///
+/// User defined proposals are passed through the protocol as an opaque value.
+pub struct CustomProposal {
+    proposal_type: ProposalType,
+    #[cfg_attr(feature = "serde", serde(with = "mls_rs_core::vec_serde"))]
+    data: Vec<u8>,
+}
+
+#[cfg(feature = "custom_proposal")]
+impl Debug for CustomProposal {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.debug_struct("CustomProposal")
+            .field("proposal_type", &self.proposal_type)
+            .field("data", &mls_rs_core::debug::pretty_bytes(&self.data))
+            .finish()
+    }
+}
+
+#[cfg(feature = "custom_proposal")]
+#[cfg_attr(all(feature = "ffi", not(test)), safer_ffi_gen::safer_ffi_gen)]
+impl CustomProposal {
+    /// Create a custom proposal.
+    ///
+    /// # Warning
+    ///
+    /// Avoid using the [`ProposalType`] values that have constants already
+    /// defined by this crate. Using existing constants in a custom proposal
+    /// has unspecified behavior.
+    pub fn new(proposal_type: ProposalType, data: Vec<u8>) -> Self {
+        Self {
+            proposal_type,
+            data,
+        }
+    }
+
+    /// The proposal type used for this custom proposal.
+    pub fn proposal_type(&self) -> ProposalType {
+        self.proposal_type
+    }
+
+    /// The opaque data communicated by this custom proposal.
+    pub fn data(&self) -> &[u8] {
+        &self.data
+    }
+}
+
+/// Trait to simplify creating custom proposals that are serialized with MLS
+/// encoding.
+#[cfg(feature = "custom_proposal")]
+pub trait MlsCustomProposal: MlsSize + MlsEncode + MlsDecode + Sized {
+    fn proposal_type() -> ProposalType;
+
+    fn to_custom_proposal(&self) -> Result<CustomProposal, mls_rs_codec::Error> {
+        Ok(CustomProposal::new(
+            Self::proposal_type(),
+            self.mls_encode_to_vec()?,
+        ))
+    }
+
+    fn from_custom_proposal(proposal: &CustomProposal) -> Result<Self, mls_rs_codec::Error> {
+        if proposal.proposal_type() != Self::proposal_type() {
+            // #[cfg(feature = "std")]
+            // return Err(mls_rs_codec::Error::Custom(
+            //     "invalid proposal type".to_string(),
+            // ));
+
+            //#[cfg(not(feature = "std"))]
+            return Err(mls_rs_codec::Error::Custom(4));
+        }
+
+        Self::mls_decode(&mut proposal.data())
+    }
+}
+
+#[allow(clippy::large_enum_variant)]
+#[derive(Clone, Debug, PartialEq)]
+#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[repr(u16)]
+#[non_exhaustive]
+/// An enum that represents all possible types of proposals.
+pub enum Proposal {
+    Add(alloc::boxed::Box<AddProposal>),
+    #[cfg(feature = "by_ref_proposal")]
+    Update(UpdateProposal),
+    Remove(RemoveProposal),
+    #[cfg(feature = "psk")]
+    Psk(PreSharedKeyProposal),
+    ReInit(ReInitProposal),
+    ExternalInit(ExternalInit),
+    GroupContextExtensions(ExtensionList),
+    #[cfg(feature = "custom_proposal")]
+    Custom(CustomProposal),
+}
+
+impl MlsSize for Proposal {
+    fn mls_encoded_len(&self) -> usize {
+        let inner_len = match self {
+            Proposal::Add(p) => p.mls_encoded_len(),
+            #[cfg(feature = "by_ref_proposal")]
+            Proposal::Update(p) => p.mls_encoded_len(),
+            Proposal::Remove(p) => p.mls_encoded_len(),
+            #[cfg(feature = "psk")]
+            Proposal::Psk(p) => p.mls_encoded_len(),
+            Proposal::ReInit(p) => p.mls_encoded_len(),
+            Proposal::ExternalInit(p) => p.mls_encoded_len(),
+            Proposal::GroupContextExtensions(p) => p.mls_encoded_len(),
+            #[cfg(feature = "custom_proposal")]
+            Proposal::Custom(p) => mls_rs_codec::byte_vec::mls_encoded_len(&p.data),
+        };
+
+        self.proposal_type().mls_encoded_len() + inner_len
+    }
+}
+
+impl MlsEncode for Proposal {
+    fn mls_encode(&self, writer: &mut Vec<u8>) -> Result<(), mls_rs_codec::Error> {
+        self.proposal_type().mls_encode(writer)?;
+
+        match self {
+            Proposal::Add(p) => p.mls_encode(writer),
+            #[cfg(feature = "by_ref_proposal")]
+            Proposal::Update(p) => p.mls_encode(writer),
+            Proposal::Remove(p) => p.mls_encode(writer),
+            #[cfg(feature = "psk")]
+            Proposal::Psk(p) => p.mls_encode(writer),
+            Proposal::ReInit(p) => p.mls_encode(writer),
+            Proposal::ExternalInit(p) => p.mls_encode(writer),
+            Proposal::GroupContextExtensions(p) => p.mls_encode(writer),
+            #[cfg(feature = "custom_proposal")]
+            Proposal::Custom(p) => {
+                if p.proposal_type.raw_value() <= 7 {
+                    // #[cfg(feature = "std")]
+                    // return Err(mls_rs_codec::Error::Custom(
+                    //     "custom proposal types can not be set to defined values of 0-7".to_string(),
+                    // ));
+
+                    // #[cfg(not(feature = "std"))]
+                    return Err(mls_rs_codec::Error::Custom(2));
+                }
+                mls_rs_codec::byte_vec::mls_encode(&p.data, writer)
+            }
+        }
+    }
+}
+
+impl MlsDecode for Proposal {
+    fn mls_decode(reader: &mut &[u8]) -> Result<Self, mls_rs_codec::Error> {
+        let proposal_type = ProposalType::mls_decode(reader)?;
+
+        Ok(match proposal_type {
+            ProposalType::ADD => {
+                Proposal::Add(alloc::boxed::Box::new(AddProposal::mls_decode(reader)?))
+            }
+            #[cfg(feature = "by_ref_proposal")]
+            ProposalType::UPDATE => Proposal::Update(UpdateProposal::mls_decode(reader)?),
+            ProposalType::REMOVE => Proposal::Remove(RemoveProposal::mls_decode(reader)?),
+            #[cfg(feature = "psk")]
+            ProposalType::PSK => Proposal::Psk(PreSharedKeyProposal::mls_decode(reader)?),
+            ProposalType::RE_INIT => Proposal::ReInit(ReInitProposal::mls_decode(reader)?),
+            ProposalType::EXTERNAL_INIT => {
+                Proposal::ExternalInit(ExternalInit::mls_decode(reader)?)
+            }
+            ProposalType::GROUP_CONTEXT_EXTENSIONS => {
+                Proposal::GroupContextExtensions(ExtensionList::mls_decode(reader)?)
+            }
+            #[cfg(feature = "custom_proposal")]
+            custom => Proposal::Custom(CustomProposal {
+                proposal_type: custom,
+                data: mls_rs_codec::byte_vec::mls_decode(reader)?,
+            }),
+            // TODO fix test dependency on openssl loading codec with default features
+            #[cfg(not(feature = "custom_proposal"))]
+            _ => return Err(mls_rs_codec::Error::Custom(3)),
+        })
+    }
+}
+
+impl Proposal {
+    pub fn proposal_type(&self) -> ProposalType {
+        match self {
+            Proposal::Add(_) => ProposalType::ADD,
+            #[cfg(feature = "by_ref_proposal")]
+            Proposal::Update(_) => ProposalType::UPDATE,
+            Proposal::Remove(_) => ProposalType::REMOVE,
+            #[cfg(feature = "psk")]
+            Proposal::Psk(_) => ProposalType::PSK,
+            Proposal::ReInit(_) => ProposalType::RE_INIT,
+            Proposal::ExternalInit(_) => ProposalType::EXTERNAL_INIT,
+            Proposal::GroupContextExtensions(_) => ProposalType::GROUP_CONTEXT_EXTENSIONS,
+            #[cfg(feature = "custom_proposal")]
+            Proposal::Custom(c) => c.proposal_type,
+        }
+    }
+}
+
+#[derive(Clone, Debug, PartialEq)]
+/// An enum that represents a borrowed version of [`Proposal`].
+pub enum BorrowedProposal<'a> {
+    Add(&'a AddProposal),
+    #[cfg(feature = "by_ref_proposal")]
+    Update(&'a UpdateProposal),
+    Remove(&'a RemoveProposal),
+    #[cfg(feature = "psk")]
+    Psk(&'a PreSharedKeyProposal),
+    ReInit(&'a ReInitProposal),
+    ExternalInit(&'a ExternalInit),
+    GroupContextExtensions(&'a ExtensionList),
+    #[cfg(feature = "custom_proposal")]
+    Custom(&'a CustomProposal),
+}
+
+impl<'a> From<BorrowedProposal<'a>> for Proposal {
+    fn from(value: BorrowedProposal<'a>) -> Self {
+        match value {
+            BorrowedProposal::Add(add) => Proposal::Add(alloc::boxed::Box::new(add.clone())),
+            #[cfg(feature = "by_ref_proposal")]
+            BorrowedProposal::Update(update) => Proposal::Update(update.clone()),
+            BorrowedProposal::Remove(remove) => Proposal::Remove(remove.clone()),
+            #[cfg(feature = "psk")]
+            BorrowedProposal::Psk(psk) => Proposal::Psk(psk.clone()),
+            BorrowedProposal::ReInit(reinit) => Proposal::ReInit(reinit.clone()),
+            BorrowedProposal::ExternalInit(external) => Proposal::ExternalInit(external.clone()),
+            BorrowedProposal::GroupContextExtensions(ext) => {
+                Proposal::GroupContextExtensions(ext.clone())
+            }
+            #[cfg(feature = "custom_proposal")]
+            BorrowedProposal::Custom(custom) => Proposal::Custom(custom.clone()),
+        }
+    }
+}
+
+impl BorrowedProposal<'_> {
+    pub fn proposal_type(&self) -> ProposalType {
+        match self {
+            BorrowedProposal::Add(_) => ProposalType::ADD,
+            #[cfg(feature = "by_ref_proposal")]
+            BorrowedProposal::Update(_) => ProposalType::UPDATE,
+            BorrowedProposal::Remove(_) => ProposalType::REMOVE,
+            #[cfg(feature = "psk")]
+            BorrowedProposal::Psk(_) => ProposalType::PSK,
+            BorrowedProposal::ReInit(_) => ProposalType::RE_INIT,
+            BorrowedProposal::ExternalInit(_) => ProposalType::EXTERNAL_INIT,
+            BorrowedProposal::GroupContextExtensions(_) => ProposalType::GROUP_CONTEXT_EXTENSIONS,
+            #[cfg(feature = "custom_proposal")]
+            BorrowedProposal::Custom(c) => c.proposal_type,
+        }
+    }
+}
+
+impl<'a> From<&'a Proposal> for BorrowedProposal<'a> {
+    fn from(p: &'a Proposal) -> Self {
+        match p {
+            Proposal::Add(p) => BorrowedProposal::Add(p),
+            #[cfg(feature = "by_ref_proposal")]
+            Proposal::Update(p) => BorrowedProposal::Update(p),
+            Proposal::Remove(p) => BorrowedProposal::Remove(p),
+            #[cfg(feature = "psk")]
+            Proposal::Psk(p) => BorrowedProposal::Psk(p),
+            Proposal::ReInit(p) => BorrowedProposal::ReInit(p),
+            Proposal::ExternalInit(p) => BorrowedProposal::ExternalInit(p),
+            Proposal::GroupContextExtensions(p) => BorrowedProposal::GroupContextExtensions(p),
+            #[cfg(feature = "custom_proposal")]
+            Proposal::Custom(p) => BorrowedProposal::Custom(p),
+        }
+    }
+}
+
+impl<'a> From<&'a AddProposal> for BorrowedProposal<'a> {
+    fn from(p: &'a AddProposal) -> Self {
+        Self::Add(p)
+    }
+}
+
+#[cfg(feature = "by_ref_proposal")]
+impl<'a> From<&'a UpdateProposal> for BorrowedProposal<'a> {
+    fn from(p: &'a UpdateProposal) -> Self {
+        Self::Update(p)
+    }
+}
+
+impl<'a> From<&'a RemoveProposal> for BorrowedProposal<'a> {
+    fn from(p: &'a RemoveProposal) -> Self {
+        Self::Remove(p)
+    }
+}
+
+#[cfg(feature = "psk")]
+impl<'a> From<&'a PreSharedKeyProposal> for BorrowedProposal<'a> {
+    fn from(p: &'a PreSharedKeyProposal) -> Self {
+        Self::Psk(p)
+    }
+}
+
+impl<'a> From<&'a ReInitProposal> for BorrowedProposal<'a> {
+    fn from(p: &'a ReInitProposal) -> Self {
+        Self::ReInit(p)
+    }
+}
+
+impl<'a> From<&'a ExternalInit> for BorrowedProposal<'a> {
+    fn from(p: &'a ExternalInit) -> Self {
+        Self::ExternalInit(p)
+    }
+}
+
+impl<'a> From<&'a ExtensionList> for BorrowedProposal<'a> {
+    fn from(p: &'a ExtensionList) -> Self {
+        Self::GroupContextExtensions(p)
+    }
+}
+
+#[cfg(feature = "custom_proposal")]
+impl<'a> From<&'a CustomProposal> for BorrowedProposal<'a> {
+    fn from(p: &'a CustomProposal) -> Self {
+        Self::Custom(p)
+    }
+}
+
+#[derive(Clone, Debug, PartialEq, MlsSize, MlsEncode, MlsDecode)]
+#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[repr(u8)]
+pub(crate) enum ProposalOrRef {
+    Proposal(Box<Proposal>) = 1u8,
+    #[cfg(feature = "by_ref_proposal")]
+    Reference(ProposalRef) = 2u8,
+}
+
+impl From<Proposal> for ProposalOrRef {
+    fn from(proposal: Proposal) -> Self {
+        Self::Proposal(Box::new(proposal))
+    }
+}
+
+#[cfg(feature = "by_ref_proposal")]
+impl From<ProposalRef> for ProposalOrRef {
+    fn from(r: ProposalRef) -> Self {
+        Self::Reference(r)
+    }
+}
diff --git a/src/group/proposal_cache.rs b/src/group/proposal_cache.rs
new file mode 100644
index 0000000..17acf79
--- /dev/null
+++ b/src/group/proposal_cache.rs
@@ -0,0 +1,4216 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// Copyright by contributors to this project.
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+
+use alloc::vec::Vec;
+
+use super::{
+    message_processor::ProvisionalState,
+    mls_rules::{CommitDirection, CommitSource, MlsRules},
+    GroupState, ProposalOrRef,
+};
+use crate::{
+    client::MlsError,
+    group::{
+        proposal_filter::{ProposalApplier, ProposalBundle, ProposalSource},
+        Proposal, Sender,
+    },
+    time::MlsTime,
+};
+
+#[cfg(feature = "by_ref_proposal")]
+use crate::group::{proposal_filter::FilterStrategy, ProposalRef, ProtocolVersion};
+
+use crate::tree_kem::leaf_node::LeafNode;
+
+#[cfg(all(feature = "std", feature = "by_ref_proposal"))]
+use std::collections::HashMap;
+
+#[cfg(feature = "by_ref_proposal")]
+use mls_rs_codec::{MlsDecode, MlsEncode, MlsSize};
+
+use mls_rs_core::{
+    crypto::CipherSuiteProvider, error::IntoAnyError, identity::IdentityProvider,
+    psk::PreSharedKeyStorage,
+};
+
+#[cfg(feature = "by_ref_proposal")]
+use core::fmt::{self, Debug};
+
+#[cfg(feature = "by_ref_proposal")]
+#[derive(Debug, Clone, MlsSize, MlsEncode, MlsDecode, PartialEq)]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+pub struct CachedProposal {
+    pub(crate) proposal: Proposal,
+    pub(crate) sender: Sender,
+}
+
+#[cfg(feature = "by_ref_proposal")]
+#[derive(Clone, PartialEq)]
+pub(crate) struct ProposalCache {
+    protocol_version: ProtocolVersion,
+    group_id: Vec<u8>,
+    #[cfg(feature = "std")]
+    pub(crate) proposals: HashMap<ProposalRef, CachedProposal>,
+    #[cfg(not(feature = "std"))]
+    pub(crate) proposals: Vec<(ProposalRef, CachedProposal)>,
+}
+
+#[cfg(feature = "by_ref_proposal")]
+impl Debug for ProposalCache {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.debug_struct("ProposalCache")
+            .field("protocol_version", &self.protocol_version)
+            .field(
+                "group_id",
+                &mls_rs_core::debug::pretty_group_id(&self.group_id),
+            )
+            .field("proposals", &self.proposals)
+            .finish()
+    }
+}
+
+#[cfg(feature = "by_ref_proposal")]
+impl ProposalCache {
+    pub fn new(protocol_version: ProtocolVersion, group_id: Vec<u8>) -> Self {
+        Self {
+            protocol_version,
+            group_id,
+            proposals: Default::default(),
+        }
+    }
+
+    pub fn import(
+        protocol_version: ProtocolVersion,
+        group_id: Vec<u8>,
+        #[cfg(feature = "std")] proposals: HashMap<ProposalRef, CachedProposal>,
+        #[cfg(not(feature = "std"))] proposals: Vec<(ProposalRef, CachedProposal)>,
+    ) -> Self {
+        Self {
+            protocol_version,
+            group_id,
+            proposals,
+        }
+    }
+
+    #[inline]
+    pub fn clear(&mut self) {
+        self.proposals.clear();
+    }
+
+    #[cfg(feature = "private_message")]
+    #[inline]
+    pub fn is_empty(&self) -> bool {
+        self.proposals.is_empty()
+    }
+
+    pub fn insert(&mut self, proposal_ref: ProposalRef, proposal: Proposal, sender: Sender) {
+        let cached_proposal = CachedProposal { proposal, sender };
+
+        #[cfg(feature = "std")]
+        self.proposals.insert(proposal_ref, cached_proposal);
+
+        #[cfg(not(feature = "std"))]
+        // This may result in dups but it does not matter
+        self.proposals.push((proposal_ref, cached_proposal));
+    }
+
+    pub fn prepare_commit(
+        &self,
+        sender: Sender,
+        additional_proposals: Vec<Proposal>,
+    ) -> ProposalBundle {
+        self.proposals
+            .iter()
+            .map(|(r, p)| {
+                (
+                    p.proposal.clone(),
+                    p.sender,
+                    ProposalSource::ByReference(r.clone()),
+                )
+            })
+            .chain(
+                additional_proposals
+                    .into_iter()
+                    .map(|p| (p, sender, ProposalSource::ByValue)),
+            )
+            .collect()
+    }
+
+    pub fn resolve_for_commit(
+        &self,
+        sender: Sender,
+        proposal_list: Vec<ProposalOrRef>,
+    ) -> Result<ProposalBundle, MlsError> {
+        let mut proposals = ProposalBundle::default();
+
+        for p in proposal_list {
+            match p {
+                ProposalOrRef::Proposal(p) => proposals.add(*p, sender, ProposalSource::ByValue),
+                ProposalOrRef::Reference(r) => {
+                    #[cfg(feature = "std")]
+                    let p = self
+                        .proposals
+                        .get(&r)
+                        .ok_or(MlsError::ProposalNotFound)?
+                        .clone();
+                    #[cfg(not(feature = "std"))]
+                    let p = self
+                        .proposals
+                        .iter()
+                        .find_map(|(rr, p)| (rr == &r).then_some(p))
+                        .ok_or(MlsError::ProposalNotFound)?
+                        .clone();
+
+                    proposals.add(p.proposal, p.sender, ProposalSource::ByReference(r));
+                }
+            };
+        }
+
+        Ok(proposals)
+    }
+}
+
+#[cfg(not(feature = "by_ref_proposal"))]
+pub(crate) fn prepare_commit(
+    sender: Sender,
+    additional_proposals: Vec<Proposal>,
+) -> ProposalBundle {
+    let mut proposals = ProposalBundle::default();
+
+    for p in additional_proposals.into_iter() {
+        proposals.add(p, sender, ProposalSource::ByValue);
+    }
+
+    proposals
+}
+
+#[cfg(not(feature = "by_ref_proposal"))]
+pub(crate) fn resolve_for_commit(
+    sender: Sender,
+    proposal_list: Vec<ProposalOrRef>,
+) -> Result<ProposalBundle, MlsError> {
+    let mut proposals = ProposalBundle::default();
+
+    for p in proposal_list {
+        let ProposalOrRef::Proposal(p) = p;
+        proposals.add(*p, sender, ProposalSource::ByValue);
+    }
+
+    Ok(proposals)
+}
+
+impl GroupState {
+    #[inline(never)]
+    #[allow(clippy::too_many_arguments)]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(crate) async fn apply_resolved<C, F, P, CSP>(
+        &self,
+        sender: Sender,
+        mut proposals: ProposalBundle,
+        external_leaf: Option<&LeafNode>,
+        identity_provider: &C,
+        cipher_suite_provider: &CSP,
+        psk_storage: &P,
+        user_rules: &F,
+        commit_time: Option<MlsTime>,
+        direction: CommitDirection,
+    ) -> Result<ProvisionalState, MlsError>
+    where
+        C: IdentityProvider,
+        F: MlsRules,
+        P: PreSharedKeyStorage,
+        CSP: CipherSuiteProvider,
+    {
+        let roster = self.public_tree.roster();
+        let group_extensions = &self.context.extensions;
+
+        #[cfg(feature = "by_ref_proposal")]
+        let all_proposals = proposals.clone();
+
+        let origin = match sender {
+            Sender::Member(index) => Ok::<_, MlsError>(CommitSource::ExistingMember(
+                roster.member_with_index(index)?,
+            )),
+            #[cfg(feature = "by_ref_proposal")]
+            Sender::NewMemberProposal => Err(MlsError::InvalidSender),
+            #[cfg(feature = "by_ref_proposal")]
+            Sender::External(_) => Err(MlsError::InvalidSender),
+            Sender::NewMemberCommit => Ok(CommitSource::NewMember(
+                external_leaf
+                    .map(|l| l.signing_identity.clone())
+                    .ok_or(MlsError::ExternalCommitMustHaveNewLeaf)?,
+            )),
+        }?;
+
+        proposals = user_rules
+            .filter_proposals(direction, origin, &roster, group_extensions, proposals)
+            .await
+            .map_err(|e| MlsError::MlsRulesError(e.into_any_error()))?;
+
+        let applier = ProposalApplier::new(
+            &self.public_tree,
+            self.context.protocol_version,
+            cipher_suite_provider,
+            group_extensions,
+            external_leaf,
+            identity_provider,
+            psk_storage,
+            #[cfg(feature = "by_ref_proposal")]
+            &self.context.group_id,
+        );
+
+        #[cfg(feature = "by_ref_proposal")]
+        let applier_output = match direction {
+            CommitDirection::Send => {
+                applier
+                    .apply_proposals(FilterStrategy::IgnoreByRef, &sender, proposals, commit_time)
+                    .await?
+            }
+            CommitDirection::Receive => {
+                applier
+                    .apply_proposals(FilterStrategy::IgnoreNone, &sender, proposals, commit_time)
+                    .await?
+            }
+        };
+
+        #[cfg(not(feature = "by_ref_proposal"))]
+        let applier_output = applier
+            .apply_proposals(&sender, &proposals, commit_time)
+            .await?;
+
+        #[cfg(feature = "by_ref_proposal")]
+        let unused_proposals = unused_proposals(
+            match direction {
+                CommitDirection::Send => all_proposals,
+                CommitDirection::Receive => self.proposals.proposals.iter().collect(),
+            },
+            &applier_output.applied_proposals,
+        );
+
+        let mut group_context = self.context.clone();
+        group_context.epoch += 1;
+
+        if let Some(ext) = applier_output.new_context_extensions {
+            group_context.extensions = ext;
+        }
+
+        #[cfg(feature = "by_ref_proposal")]
+        let proposals = applier_output.applied_proposals;
+
+        Ok(ProvisionalState {
+            public_tree: applier_output.new_tree,
+            group_context,
+            applied_proposals: proposals,
+            external_init_index: applier_output.external_init_index,
+            indexes_of_added_kpkgs: applier_output.indexes_of_added_kpkgs,
+            #[cfg(feature = "by_ref_proposal")]
+            unused_proposals,
+        })
+    }
+}
+
+#[cfg(feature = "by_ref_proposal")]
+impl Extend<(ProposalRef, CachedProposal)> for ProposalCache {
+    fn extend<T>(&mut self, iter: T)
+    where
+        T: IntoIterator<Item = (ProposalRef, CachedProposal)>,
+    {
+        self.proposals.extend(iter);
+    }
+}
+
+#[cfg(feature = "by_ref_proposal")]
+fn has_ref(proposals: &ProposalBundle, reference: &ProposalRef) -> bool {
+    proposals
+        .iter_proposals()
+        .any(|p| matches!(&p.source, ProposalSource::ByReference(r) if r == reference))
+}
+
+#[cfg(feature = "by_ref_proposal")]
+fn unused_proposals(
+    all_proposals: ProposalBundle,
+    accepted_proposals: &ProposalBundle,
+) -> Vec<crate::mls_rules::ProposalInfo<Proposal>> {
+    all_proposals
+        .into_proposals()
+        .filter(|p| {
+            matches!(p.source, ProposalSource::ByReference(ref r) if !has_ref(accepted_proposals, r)
+            )
+        })
+        .collect()
+}
+
+// TODO add tests for lite version of filtering
+#[cfg(all(feature = "by_ref_proposal", test))]
+pub(crate) mod test_utils {
+    use mls_rs_core::{
+        crypto::CipherSuiteProvider, extension::ExtensionList, identity::IdentityProvider,
+        psk::PreSharedKeyStorage,
+    };
+
+    use crate::{
+        client::test_utils::TEST_PROTOCOL_VERSION,
+        group::{
+            confirmation_tag::ConfirmationTag,
+            mls_rules::{CommitDirection, DefaultMlsRules, MlsRules},
+            proposal::{Proposal, ProposalOrRef},
+            proposal_ref::ProposalRef,
+            state::GroupState,
+            test_utils::{get_test_group_context, TEST_GROUP},
+            GroupContext, LeafIndex, LeafNode, ProvisionalState, Sender, TreeKemPublic,
+        },
+        identity::{basic::BasicIdentityProvider, test_utils::BasicWithCustomProvider},
+        psk::AlwaysFoundPskStorage,
+    };
+
+    use super::{CachedProposal, MlsError, ProposalCache};
+
+    use alloc::vec::Vec;
+
+    impl CachedProposal {
+        pub fn new(proposal: Proposal, sender: Sender) -> Self {
+            Self { proposal, sender }
+        }
+    }
+
+    #[derive(Debug)]
+    pub(crate) struct CommitReceiver<'a, C, F, P, CSP> {
+        tree: &'a TreeKemPublic,
+        sender: Sender,
+        receiver: LeafIndex,
+        cache: ProposalCache,
+        identity_provider: C,
+        cipher_suite_provider: CSP,
+        group_context_extensions: ExtensionList,
+        user_rules: F,
+        with_psk_storage: P,
+    }
+
+    impl<'a, CSP>
+        CommitReceiver<'a, BasicWithCustomProvider, DefaultMlsRules, AlwaysFoundPskStorage, CSP>
+    {
+        pub fn new<S>(
+            tree: &'a TreeKemPublic,
+            sender: S,
+            receiver: LeafIndex,
+            cipher_suite_provider: CSP,
+        ) -> Self
+        where
+            S: Into<Sender>,
+        {
+            Self {
+                tree,
+                sender: sender.into(),
+                receiver,
+                cache: make_proposal_cache(),
+                identity_provider: BasicWithCustomProvider::new(BasicIdentityProvider),
+                group_context_extensions: Default::default(),
+                user_rules: pass_through_rules(),
+                with_psk_storage: AlwaysFoundPskStorage,
+                cipher_suite_provider,
+            }
+        }
+    }
+
+    impl<'a, C, F, P, CSP> CommitReceiver<'a, C, F, P, CSP>
+    where
+        C: IdentityProvider,
+        F: MlsRules,
+        P: PreSharedKeyStorage,
+        CSP: CipherSuiteProvider,
+    {
+        #[cfg(feature = "by_ref_proposal")]
+        pub fn with_identity_provider<V>(self, validator: V) -> CommitReceiver<'a, V, F, P, CSP>
+        where
+            V: IdentityProvider,
+        {
+            CommitReceiver {
+                tree: self.tree,
+                sender: self.sender,
+                receiver: self.receiver,
+                cache: self.cache,
+                identity_provider: validator,
+                group_context_extensions: self.group_context_extensions,
+                user_rules: self.user_rules,
+                with_psk_storage: self.with_psk_storage,
+                cipher_suite_provider: self.cipher_suite_provider,
+            }
+        }
+
+        pub fn with_user_rules<G>(self, f: G) -> CommitReceiver<'a, C, G, P, CSP>
+        where
+            G: MlsRules,
+        {
+            CommitReceiver {
+                tree: self.tree,
+                sender: self.sender,
+                receiver: self.receiver,
+                cache: self.cache,
+                identity_provider: self.identity_provider,
+                group_context_extensions: self.group_context_extensions,
+                user_rules: f,
+                with_psk_storage: self.with_psk_storage,
+                cipher_suite_provider: self.cipher_suite_provider,
+            }
+        }
+
+        pub fn with_psk_storage<V>(self, v: V) -> CommitReceiver<'a, C, F, V, CSP>
+        where
+            V: PreSharedKeyStorage,
+        {
+            CommitReceiver {
+                tree: self.tree,
+                sender: self.sender,
+                receiver: self.receiver,
+                cache: self.cache,
+                identity_provider: self.identity_provider,
+                group_context_extensions: self.group_context_extensions,
+                user_rules: self.user_rules,
+                with_psk_storage: v,
+                cipher_suite_provider: self.cipher_suite_provider,
+            }
+        }
+
+        #[cfg(feature = "by_ref_proposal")]
+        pub fn with_extensions(self, extensions: ExtensionList) -> Self {
+            Self {
+                group_context_extensions: extensions,
+                ..self
+            }
+        }
+
+        pub fn cache<S>(mut self, r: ProposalRef, p: Proposal, proposer: S) -> Self
+        where
+            S: Into<Sender>,
+        {
+            self.cache.insert(r, p, proposer.into());
+            self
+        }
+
+        #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+        pub async fn receive<I>(&self, proposals: I) -> Result<ProvisionalState, MlsError>
+        where
+            I: IntoIterator,
+            I::Item: Into<ProposalOrRef>,
+        {
+            self.cache
+                .resolve_for_commit_default(
+                    self.sender,
+                    proposals.into_iter().map(Into::into).collect(),
+                    None,
+                    &self.group_context_extensions,
+                    &self.identity_provider,
+                    &self.cipher_suite_provider,
+                    self.tree,
+                    &self.with_psk_storage,
+                    &self.user_rules,
+                )
+                .await
+        }
+    }
+
+    pub(crate) fn make_proposal_cache() -> ProposalCache {
+        ProposalCache::new(TEST_PROTOCOL_VERSION, TEST_GROUP.to_vec())
+    }
+
+    pub fn pass_through_rules() -> DefaultMlsRules {
+        DefaultMlsRules::new()
+    }
+
+    impl ProposalCache {
+        #[allow(clippy::too_many_arguments)]
+        #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+        pub async fn resolve_for_commit_default<C, F, P, CSP>(
+            &self,
+            sender: Sender,
+            proposal_list: Vec<ProposalOrRef>,
+            external_leaf: Option<&LeafNode>,
+            group_extensions: &ExtensionList,
+            identity_provider: &C,
+            cipher_suite_provider: &CSP,
+            public_tree: &TreeKemPublic,
+            psk_storage: &P,
+            user_rules: F,
+        ) -> Result<ProvisionalState, MlsError>
+        where
+            C: IdentityProvider,
+            F: MlsRules,
+            P: PreSharedKeyStorage,
+            CSP: CipherSuiteProvider,
+        {
+            let mut context =
+                get_test_group_context(123, cipher_suite_provider.cipher_suite()).await;
+
+            context.extensions = group_extensions.clone();
+
+            let mut state = GroupState::new(
+                context,
+                public_tree.clone(),
+                Vec::new().into(),
+                ConfirmationTag::empty(cipher_suite_provider).await,
+            );
+
+            state.proposals.proposals = self.proposals.clone();
+            let proposals = self.resolve_for_commit(sender, proposal_list)?;
+
+            state
+                .apply_resolved(
+                    sender,
+                    proposals,
+                    external_leaf,
+                    identity_provider,
+                    cipher_suite_provider,
+                    psk_storage,
+                    &user_rules,
+                    None,
+                    CommitDirection::Receive,
+                )
+                .await
+        }
+
+        #[allow(clippy::too_many_arguments)]
+        #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+        pub async fn prepare_commit_default<C, F, P, CSP>(
+            &self,
+            sender: Sender,
+            additional_proposals: Vec<Proposal>,
+            context: &GroupContext,
+            identity_provider: &C,
+            cipher_suite_provider: &CSP,
+            public_tree: &TreeKemPublic,
+            external_leaf: Option<&LeafNode>,
+            psk_storage: &P,
+            user_rules: F,
+        ) -> Result<ProvisionalState, MlsError>
+        where
+            C: IdentityProvider,
+            F: MlsRules,
+            P: PreSharedKeyStorage,
+            CSP: CipherSuiteProvider,
+        {
+            let state = GroupState::new(
+                context.clone(),
+                public_tree.clone(),
+                Vec::new().into(),
+                ConfirmationTag::empty(cipher_suite_provider).await,
+            );
+
+            let proposals = self.prepare_commit(sender, additional_proposals);
+
+            state
+                .apply_resolved(
+                    sender,
+                    proposals,
+                    external_leaf,
+                    identity_provider,
+                    cipher_suite_provider,
+                    psk_storage,
+                    &user_rules,
+                    None,
+                    CommitDirection::Send,
+                )
+                .await
+        }
+    }
+}
+
+// TODO add tests for lite version of filtering
+#[cfg(all(feature = "by_ref_proposal", test))]
+mod tests {
+    use alloc::{boxed::Box, vec, vec::Vec};
+
+    use super::test_utils::{make_proposal_cache, pass_through_rules, CommitReceiver};
+    use super::{CachedProposal, ProposalCache};
+    use crate::client::MlsError;
+    use crate::group::message_processor::ProvisionalState;
+    use crate::group::mls_rules::{CommitDirection, CommitSource, EncryptionOptions};
+    use crate::group::proposal_filter::{ProposalBundle, ProposalInfo, ProposalSource};
+    use crate::group::proposal_ref::test_utils::auth_content_from_proposal;
+    use crate::group::proposal_ref::ProposalRef;
+    use crate::group::{
+        AddProposal, AuthenticatedContent, Content, ExternalInit, Proposal, ProposalOrRef,
+        ReInitProposal, RemoveProposal, Roster, Sender, UpdateProposal,
+    };
+    use crate::key_package::test_utils::test_key_package_with_signer;
+    use crate::signer::Signable;
+    use crate::tree_kem::leaf_node::LeafNode;
+    use crate::tree_kem::node::LeafIndex;
+    use crate::tree_kem::TreeKemPublic;
+    use crate::{
+        client::test_utils::{TEST_CIPHER_SUITE, TEST_PROTOCOL_VERSION},
+        crypto::{self, test_utils::test_cipher_suite_provider},
+        extension::test_utils::TestExtension,
+        group::{
+            message_processor::path_update_required,
+            proposal_filter::proposer_can_propose,
+            test_utils::{get_test_group_context, random_bytes, test_group, TEST_GROUP},
+        },
+        identity::basic::BasicIdentityProvider,
+        identity::test_utils::{get_test_signing_identity, BasicWithCustomProvider},
+        key_package::{test_utils::test_key_package, KeyPackageGenerator},
+        mls_rules::{CommitOptions, DefaultMlsRules},
+        psk::AlwaysFoundPskStorage,
+        tree_kem::{
+            leaf_node::{
+                test_utils::{
+                    default_properties, get_basic_test_node, get_basic_test_node_capabilities,
+                    get_basic_test_node_sig_key, get_test_capabilities,
+                },
+                ConfigProperties, LeafNodeSigningContext, LeafNodeSource,
+            },
+            Lifetime,
+        },
+    };
+    use crate::{KeyPackage, MlsRules};
+
+    use crate::extension::RequiredCapabilitiesExt;
+
+    #[cfg(feature = "by_ref_proposal")]
+    use crate::{
+        extension::ExternalSendersExt,
+        tree_kem::leaf_node_validator::test_utils::FailureIdentityProvider,
+    };
+
+    #[cfg(feature = "psk")]
+    use crate::{
+        group::proposal::PreSharedKeyProposal,
+        psk::{
+            ExternalPskId, JustPreSharedKeyID, PreSharedKeyID, PskGroupId, PskNonce,
+            ResumptionPSKUsage, ResumptionPsk,
+        },
+    };
+
+    #[cfg(feature = "custom_proposal")]
+    use crate::group::proposal::CustomProposal;
+
+    use assert_matches::assert_matches;
+    use core::convert::Infallible;
+    use itertools::Itertools;
+    use mls_rs_core::crypto::{CipherSuite, CipherSuiteProvider};
+    use mls_rs_core::extension::ExtensionList;
+    use mls_rs_core::group::{Capabilities, ProposalType};
+    use mls_rs_core::identity::IdentityProvider;
+    use mls_rs_core::protocol_version::ProtocolVersion;
+    use mls_rs_core::psk::{PreSharedKey, PreSharedKeyStorage};
+    use mls_rs_core::{
+        extension::MlsExtension,
+        identity::{Credential, CredentialType, CustomCredential},
+    };
+
+    fn test_sender() -> u32 {
+        1
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn new_tree_custom_proposals(
+        name: &str,
+        proposal_types: Vec<ProposalType>,
+    ) -> (LeafIndex, TreeKemPublic) {
+        let (leaf, secret, _) = get_basic_test_node_capabilities(
+            TEST_CIPHER_SUITE,
+            name,
+            Capabilities {
+                proposals: proposal_types,
+                ..get_test_capabilities()
+            },
+        )
+        .await;
+
+        let (pub_tree, priv_tree) =
+            TreeKemPublic::derive(leaf, secret, &BasicIdentityProvider, &Default::default())
+                .await
+                .unwrap();
+
+        (priv_tree.self_index, pub_tree)
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn new_tree(name: &str) -> (LeafIndex, TreeKemPublic) {
+        new_tree_custom_proposals(name, vec![]).await
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn add_member(tree: &mut TreeKemPublic, name: &str) -> LeafIndex {
+        let test_node = get_basic_test_node(TEST_CIPHER_SUITE, name).await;
+
+        tree.add_leaves(
+            vec![test_node],
+            &BasicIdentityProvider,
+            &test_cipher_suite_provider(TEST_CIPHER_SUITE),
+        )
+        .await
+        .unwrap()[0]
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn update_leaf_node(name: &str, leaf_index: u32) -> LeafNode {
+        let (mut leaf, _, signer) = get_basic_test_node_sig_key(TEST_CIPHER_SUITE, name).await;
+
+        leaf.update(
+            &test_cipher_suite_provider(TEST_CIPHER_SUITE),
+            TEST_GROUP,
+            leaf_index,
+            default_properties(),
+            None,
+            &signer,
+        )
+        .await
+        .unwrap();
+
+        leaf
+    }
+
+    struct TestProposals {
+        test_sender: u32,
+        test_proposals: Vec<AuthenticatedContent>,
+        expected_effects: ProvisionalState,
+        tree: TreeKemPublic,
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn test_proposals(
+        protocol_version: ProtocolVersion,
+        cipher_suite: CipherSuite,
+    ) -> TestProposals {
+        let cipher_suite_provider = test_cipher_suite_provider(cipher_suite);
+
+        let (sender_leaf, sender_leaf_secret, _) =
+            get_basic_test_node_sig_key(cipher_suite, "alice").await;
+
+        let sender = LeafIndex(0);
+
+        let (mut tree, _) = TreeKemPublic::derive(
+            sender_leaf,
+            sender_leaf_secret,
+            &BasicIdentityProvider,
+            &Default::default(),
+        )
+        .await
+        .unwrap();
+
+        let add_package = test_key_package(protocol_version, cipher_suite, "dave").await;
+
+        let remove_leaf_index = add_member(&mut tree, "carol").await;
+
+        let add = Proposal::Add(Box::new(AddProposal {
+            key_package: add_package.clone(),
+        }));
+
+        let remove = Proposal::Remove(RemoveProposal {
+            to_remove: remove_leaf_index,
+        });
+
+        let extensions = Proposal::GroupContextExtensions(ExtensionList::new());
+
+        let proposals = vec![add, remove, extensions];
+
+        let test_node = get_basic_test_node(cipher_suite, "charlie").await;
+
+        let test_sender = *tree
+            .add_leaves(
+                vec![test_node],
+                &BasicIdentityProvider,
+                &cipher_suite_provider,
+            )
+            .await
+            .unwrap()[0];
+
+        let mut expected_tree = tree.clone();
+
+        let mut bundle = ProposalBundle::default();
+
+        let plaintext = proposals
+            .iter()
+            .cloned()
+            .map(|p| auth_content_from_proposal(p, sender))
+            .collect_vec();
+
+        for i in 0..proposals.len() {
+            let pref = ProposalRef::from_content(&cipher_suite_provider, &plaintext[i])
+                .await
+                .unwrap();
+
+            bundle.add(
+                proposals[i].clone(),
+                Sender::Member(test_sender),
+                ProposalSource::ByReference(pref),
+            )
+        }
+
+        expected_tree
+            .batch_edit(
+                &mut bundle,
+                &Default::default(),
+                &BasicIdentityProvider,
+                &cipher_suite_provider,
+                true,
+            )
+            .await
+            .unwrap();
+
+        let expected_effects = ProvisionalState {
+            public_tree: expected_tree,
+            group_context: get_test_group_context(1, cipher_suite).await,
+            external_init_index: None,
+            indexes_of_added_kpkgs: vec![LeafIndex(1)],
+            #[cfg(feature = "state_update")]
+            unused_proposals: vec![],
+            applied_proposals: bundle,
+        };
+
+        TestProposals {
+            test_sender,
+            test_proposals: plaintext,
+            expected_effects,
+            tree,
+        }
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn filter_proposals(
+        cipher_suite: CipherSuite,
+        proposals: Vec<AuthenticatedContent>,
+    ) -> Vec<(ProposalRef, CachedProposal)> {
+        let mut contents = Vec::new();
+
+        for p in proposals {
+            if let Content::Proposal(proposal) = &p.content.content {
+                let proposal_ref =
+                    ProposalRef::from_content(&test_cipher_suite_provider(cipher_suite), &p)
+                        .await
+                        .unwrap();
+                contents.push((
+                    proposal_ref,
+                    CachedProposal::new(proposal.as_ref().clone(), p.content.sender),
+                ));
+            }
+        }
+
+        contents
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn make_proposal_ref<S>(p: &Proposal, sender: S) -> ProposalRef
+    where
+        S: Into<Sender>,
+    {
+        ProposalRef::from_content(
+            &test_cipher_suite_provider(TEST_CIPHER_SUITE),
+            &auth_content_from_proposal(p.clone(), sender),
+        )
+        .await
+        .unwrap()
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn make_proposal_info<S>(p: &Proposal, sender: S) -> ProposalInfo<Proposal>
+    where
+        S: Into<Sender> + Clone,
+    {
+        ProposalInfo {
+            proposal: p.clone(),
+            sender: sender.clone().into(),
+            source: ProposalSource::ByReference(make_proposal_ref(p, sender).await),
+        }
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn test_proposal_cache_setup(proposals: Vec<AuthenticatedContent>) -> ProposalCache {
+        let mut cache = make_proposal_cache();
+        cache.extend(filter_proposals(TEST_CIPHER_SUITE, proposals).await);
+        cache
+    }
+
+    fn assert_matches(mut expected_state: ProvisionalState, state: ProvisionalState) {
+        let expected_proposals = expected_state.applied_proposals.into_proposals_or_refs();
+        let proposals = state.applied_proposals.into_proposals_or_refs();
+
+        assert_eq!(proposals.len(), expected_proposals.len());
+
+        // Determine there are no duplicates in the proposals returned
+        assert!(!proposals.iter().enumerate().any(|(i, p1)| proposals
+            .iter()
+            .enumerate()
+            .any(|(j, p2)| p1 == p2 && i != j)),);
+
+        // Proposal order may change so we just compare the length and contents are the same
+        expected_proposals
+            .iter()
+            .for_each(|p| assert!(proposals.contains(p)));
+
+        assert_eq!(
+            expected_state.external_init_index,
+            state.external_init_index
+        );
+
+        // We don't compare the epoch in this test.
+        expected_state.group_context.epoch = state.group_context.epoch;
+        assert_eq!(expected_state.group_context, state.group_context);
+
+        assert_eq!(
+            expected_state.indexes_of_added_kpkgs,
+            state.indexes_of_added_kpkgs
+        );
+
+        assert_eq!(expected_state.public_tree, state.public_tree);
+
+        #[cfg(feature = "state_update")]
+        assert_eq!(expected_state.unused_proposals, state.unused_proposals);
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_proposal_cache_commit_all_cached() {
+        let cipher_suite_provider = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+
+        let TestProposals {
+            test_sender,
+            test_proposals,
+            expected_effects,
+            tree,
+            ..
+        } = test_proposals(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await;
+
+        let cache = test_proposal_cache_setup(test_proposals.clone()).await;
+
+        let provisional_state = cache
+            .prepare_commit_default(
+                Sender::Member(test_sender),
+                vec![],
+                &get_test_group_context(0, TEST_CIPHER_SUITE).await,
+                &BasicIdentityProvider,
+                &cipher_suite_provider,
+                &tree,
+                None,
+                &AlwaysFoundPskStorage,
+                pass_through_rules(),
+            )
+            .await
+            .unwrap();
+
+        assert_matches(expected_effects, provisional_state)
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_proposal_cache_commit_additional() {
+        let cipher_suite_provider = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+
+        let TestProposals {
+            test_sender,
+            test_proposals,
+            mut expected_effects,
+            tree,
+            ..
+        } = test_proposals(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await;
+
+        let additional_key_package =
+            test_key_package(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, "frank").await;
+
+        let additional = AddProposal {
+            key_package: additional_key_package.clone(),
+        };
+
+        let cache = test_proposal_cache_setup(test_proposals.clone()).await;
+
+        let provisional_state = cache
+            .prepare_commit_default(
+                Sender::Member(test_sender),
+                vec![Proposal::Add(Box::new(additional.clone()))],
+                &get_test_group_context(0, TEST_CIPHER_SUITE).await,
+                &BasicIdentityProvider,
+                &cipher_suite_provider,
+                &tree,
+                None,
+                &AlwaysFoundPskStorage,
+                pass_through_rules(),
+            )
+            .await
+            .unwrap();
+
+        expected_effects.applied_proposals.add(
+            Proposal::Add(Box::new(additional.clone())),
+            Sender::Member(test_sender),
+            ProposalSource::ByValue,
+        );
+
+        let leaf = vec![additional_key_package.leaf_node.clone()];
+
+        expected_effects
+            .public_tree
+            .add_leaves(leaf, &BasicIdentityProvider, &cipher_suite_provider)
+            .await
+            .unwrap();
+
+        expected_effects.indexes_of_added_kpkgs.push(LeafIndex(3));
+
+        assert_matches(expected_effects, provisional_state);
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_proposal_cache_update_filter() {
+        let cipher_suite_provider = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+
+        let TestProposals {
+            test_proposals,
+            tree,
+            ..
+        } = test_proposals(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await;
+
+        let update_proposal = make_update_proposal("foo").await;
+
+        let additional = vec![Proposal::Update(update_proposal)];
+
+        let cache = test_proposal_cache_setup(test_proposals).await;
+
+        let res = cache
+            .prepare_commit_default(
+                Sender::Member(test_sender()),
+                additional,
+                &get_test_group_context(0, TEST_CIPHER_SUITE).await,
+                &BasicIdentityProvider,
+                &cipher_suite_provider,
+                &tree,
+                None,
+                &AlwaysFoundPskStorage,
+                pass_through_rules(),
+            )
+            .await;
+
+        assert_matches!(res, Err(MlsError::InvalidProposalTypeForSender));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_proposal_cache_removal_override_update() {
+        let cipher_suite_provider = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+
+        let TestProposals {
+            test_sender,
+            test_proposals,
+            tree,
+            ..
+        } = test_proposals(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await;
+
+        let update = Proposal::Update(make_update_proposal("foo").await);
+        let update_proposal_ref = make_proposal_ref(&update, LeafIndex(1)).await;
+        let mut cache = test_proposal_cache_setup(test_proposals).await;
+
+        cache.insert(update_proposal_ref.clone(), update, Sender::Member(1));
+
+        let provisional_state = cache
+            .prepare_commit_default(
+                Sender::Member(test_sender),
+                vec![],
+                &get_test_group_context(0, TEST_CIPHER_SUITE).await,
+                &BasicIdentityProvider,
+                &cipher_suite_provider,
+                &tree,
+                None,
+                &AlwaysFoundPskStorage,
+                pass_through_rules(),
+            )
+            .await
+            .unwrap();
+
+        assert!(provisional_state
+            .applied_proposals
+            .removals
+            .iter()
+            .any(|p| *p.proposal.to_remove == 1));
+
+        assert!(!provisional_state
+            .applied_proposals
+            .into_proposals_or_refs()
+            .contains(&ProposalOrRef::Reference(update_proposal_ref)))
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_proposal_cache_filter_duplicates_insert() {
+        let cipher_suite_provider = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+
+        let TestProposals {
+            test_sender,
+            test_proposals,
+            expected_effects,
+            tree,
+            ..
+        } = test_proposals(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await;
+
+        let mut cache = test_proposal_cache_setup(test_proposals.clone()).await;
+        cache.extend(filter_proposals(TEST_CIPHER_SUITE, test_proposals.clone()).await);
+
+        let provisional_state = cache
+            .prepare_commit_default(
+                Sender::Member(test_sender),
+                vec![],
+                &get_test_group_context(0, TEST_CIPHER_SUITE).await,
+                &BasicIdentityProvider,
+                &cipher_suite_provider,
+                &tree,
+                None,
+                &AlwaysFoundPskStorage,
+                pass_through_rules(),
+            )
+            .await
+            .unwrap();
+
+        assert_matches(expected_effects, provisional_state)
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_proposal_cache_filter_duplicates_additional() {
+        let cipher_suite_provider = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+
+        let TestProposals {
+            test_proposals,
+            expected_effects,
+            tree,
+            ..
+        } = test_proposals(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await;
+
+        let mut cache = test_proposal_cache_setup(test_proposals.clone()).await;
+
+        // Updates from different senders will be allowed so we test duplicates for add / remove
+        let additional = test_proposals
+            .clone()
+            .into_iter()
+            .filter_map(|plaintext| match plaintext.content.content {
+                Content::Proposal(p) if p.proposal_type() == ProposalType::UPDATE => None,
+                Content::Proposal(_) => Some(plaintext),
+                _ => None,
+            })
+            .collect::<Vec<_>>();
+
+        cache.extend(filter_proposals(TEST_CIPHER_SUITE, additional).await);
+
+        let provisional_state = cache
+            .prepare_commit_default(
+                Sender::Member(2),
+                Vec::new(),
+                &get_test_group_context(0, TEST_CIPHER_SUITE).await,
+                &BasicIdentityProvider,
+                &cipher_suite_provider,
+                &tree,
+                None,
+                &AlwaysFoundPskStorage,
+                pass_through_rules(),
+            )
+            .await
+            .unwrap();
+
+        assert_matches(expected_effects, provisional_state)
+    }
+
+    #[cfg(feature = "private_message")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_proposal_cache_is_empty() {
+        let mut cache = make_proposal_cache();
+        assert!(cache.is_empty());
+
+        let test_proposal = Proposal::Remove(RemoveProposal {
+            to_remove: LeafIndex(test_sender()),
+        });
+
+        let proposer = test_sender();
+        let test_proposal_ref = make_proposal_ref(&test_proposal, LeafIndex(proposer)).await;
+        cache.insert(test_proposal_ref, test_proposal, Sender::Member(proposer));
+
+        assert!(!cache.is_empty())
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_proposal_cache_resolve() {
+        let cipher_suite_provider = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+
+        let TestProposals {
+            test_sender,
+            test_proposals,
+            tree,
+            ..
+        } = test_proposals(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await;
+
+        let cache = test_proposal_cache_setup(test_proposals).await;
+
+        let proposal = Proposal::Add(Box::new(AddProposal {
+            key_package: test_key_package(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, "frank").await,
+        }));
+
+        let additional = vec![proposal];
+
+        let expected_effects = cache
+            .prepare_commit_default(
+                Sender::Member(test_sender),
+                additional,
+                &get_test_group_context(0, TEST_CIPHER_SUITE).await,
+                &BasicIdentityProvider,
+                &cipher_suite_provider,
+                &tree,
+                None,
+                &AlwaysFoundPskStorage,
+                pass_through_rules(),
+            )
+            .await
+            .unwrap();
+
+        let proposals = expected_effects
+            .applied_proposals
+            .clone()
+            .into_proposals_or_refs();
+
+        let resolution = cache
+            .resolve_for_commit_default(
+                Sender::Member(test_sender),
+                proposals,
+                None,
+                &ExtensionList::new(),
+                &BasicIdentityProvider,
+                &cipher_suite_provider,
+                &tree,
+                &AlwaysFoundPskStorage,
+                pass_through_rules(),
+            )
+            .await
+            .unwrap();
+
+        assert_matches(expected_effects, resolution);
+    }
+
+    #[cfg(feature = "psk")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn proposal_cache_filters_duplicate_psk_ids() {
+        let cipher_suite_provider = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+
+        let (alice, tree) = new_tree("alice").await;
+        let cache = make_proposal_cache();
+
+        let proposal = Proposal::Psk(make_external_psk(
+            b"ted",
+            crate::psk::PskNonce::random(&test_cipher_suite_provider(TEST_CIPHER_SUITE)).unwrap(),
+        ));
+
+        let res = cache
+            .prepare_commit_default(
+                Sender::Member(*alice),
+                vec![proposal.clone(), proposal],
+                &get_test_group_context(0, TEST_CIPHER_SUITE).await,
+                &BasicIdentityProvider,
+                &cipher_suite_provider,
+                &tree,
+                None,
+                &AlwaysFoundPskStorage,
+                pass_through_rules(),
+            )
+            .await;
+
+        assert_matches!(res, Err(MlsError::DuplicatePskIds));
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn test_node() -> LeafNode {
+        let (mut leaf_node, _, signer) =
+            get_basic_test_node_sig_key(TEST_CIPHER_SUITE, "foo").await;
+
+        leaf_node
+            .commit(
+                &test_cipher_suite_provider(TEST_CIPHER_SUITE),
+                TEST_GROUP,
+                0,
+                default_properties(),
+                None,
+                &signer,
+            )
+            .await
+            .unwrap();
+
+        leaf_node
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn external_commit_must_have_new_leaf() {
+        let cache = make_proposal_cache();
+        let cipher_suite_provider = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+
+        let kem_output = vec![0; cipher_suite_provider.kdf_extract_size()];
+        let group = test_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await;
+        let public_tree = &group.group.state.public_tree;
+
+        let res = cache
+            .resolve_for_commit_default(
+                Sender::NewMemberCommit,
+                vec![ProposalOrRef::Proposal(Box::new(Proposal::ExternalInit(
+                    ExternalInit { kem_output },
+                )))],
+                None,
+                &group.group.context().extensions,
+                &BasicIdentityProvider,
+                &cipher_suite_provider,
+                public_tree,
+                &AlwaysFoundPskStorage,
+                pass_through_rules(),
+            )
+            .await;
+
+        assert_matches!(res, Err(MlsError::ExternalCommitMustHaveNewLeaf));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn proposal_cache_rejects_proposals_by_ref_for_new_member() {
+        let mut cache = make_proposal_cache();
+        let cipher_suite_provider = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+
+        let proposal = {
+            let kem_output = vec![0; cipher_suite_provider.kdf_extract_size()];
+            Proposal::ExternalInit(ExternalInit { kem_output })
+        };
+
+        let proposal_ref = make_proposal_ref(&proposal, test_sender()).await;
+
+        cache.insert(
+            proposal_ref.clone(),
+            proposal,
+            Sender::Member(test_sender()),
+        );
+
+        let group = test_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await;
+        let public_tree = &group.group.state.public_tree;
+
+        let res = cache
+            .resolve_for_commit_default(
+                Sender::NewMemberCommit,
+                vec![ProposalOrRef::Reference(proposal_ref)],
+                Some(&test_node().await),
+                &group.group.context().extensions,
+                &BasicIdentityProvider,
+                &cipher_suite_provider,
+                public_tree,
+                &AlwaysFoundPskStorage,
+                pass_through_rules(),
+            )
+            .await;
+
+        assert_matches!(res, Err(MlsError::OnlyMembersCanCommitProposalsByRef));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn proposal_cache_rejects_multiple_external_init_proposals_in_commit() {
+        let cache = make_proposal_cache();
+        let cipher_suite_provider = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+        let kem_output = vec![0; cipher_suite_provider.kdf_extract_size()];
+        let group = test_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await;
+        let public_tree = &group.group.state.public_tree;
+
+        let res = cache
+            .resolve_for_commit_default(
+                Sender::NewMemberCommit,
+                [
+                    Proposal::ExternalInit(ExternalInit {
+                        kem_output: kem_output.clone(),
+                    }),
+                    Proposal::ExternalInit(ExternalInit { kem_output }),
+                ]
+                .into_iter()
+                .map(|p| ProposalOrRef::Proposal(Box::new(p)))
+                .collect(),
+                Some(&test_node().await),
+                &group.group.context().extensions,
+                &BasicIdentityProvider,
+                &cipher_suite_provider,
+                public_tree,
+                &AlwaysFoundPskStorage,
+                pass_through_rules(),
+            )
+            .await;
+
+        assert_matches!(
+            res,
+            Err(MlsError::ExternalCommitMustHaveExactlyOneExternalInit)
+        );
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn new_member_commits_proposal(proposal: Proposal) -> Result<ProvisionalState, MlsError> {
+        let cache = make_proposal_cache();
+        let cipher_suite_provider = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+        let kem_output = vec![0; cipher_suite_provider.kdf_extract_size()];
+        let group = test_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await;
+        let public_tree = &group.group.state.public_tree;
+
+        cache
+            .resolve_for_commit_default(
+                Sender::NewMemberCommit,
+                [
+                    Proposal::ExternalInit(ExternalInit { kem_output }),
+                    proposal,
+                ]
+                .into_iter()
+                .map(|p| ProposalOrRef::Proposal(Box::new(p)))
+                .collect(),
+                Some(&test_node().await),
+                &group.group.context().extensions,
+                &BasicIdentityProvider,
+                &cipher_suite_provider,
+                public_tree,
+                &AlwaysFoundPskStorage,
+                pass_through_rules(),
+            )
+            .await
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn new_member_cannot_commit_add_proposal() {
+        let res = new_member_commits_proposal(Proposal::Add(Box::new(AddProposal {
+            key_package: test_key_package(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, "frank").await,
+        })))
+        .await;
+
+        assert_matches!(
+            res,
+            Err(MlsError::InvalidProposalTypeInExternalCommit(
+                ProposalType::ADD
+            ))
+        );
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn new_member_cannot_commit_more_than_one_remove_proposal() {
+        let cache = make_proposal_cache();
+        let cipher_suite_provider = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+        let kem_output = vec![0; cipher_suite_provider.kdf_extract_size()];
+        let group = test_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await;
+        let group_extensions = group.group.context().extensions.clone();
+        let mut public_tree = group.group.state.public_tree;
+
+        let foo = get_basic_test_node(TEST_CIPHER_SUITE, "foo").await;
+
+        let bar = get_basic_test_node(TEST_CIPHER_SUITE, "bar").await;
+
+        let test_leaf_nodes = vec![foo, bar];
+
+        let test_leaf_node_indexes = public_tree
+            .add_leaves(
+                test_leaf_nodes,
+                &BasicIdentityProvider,
+                &cipher_suite_provider,
+            )
+            .await
+            .unwrap();
+
+        let proposals = vec![
+            Proposal::ExternalInit(ExternalInit { kem_output }),
+            Proposal::Remove(RemoveProposal {
+                to_remove: test_leaf_node_indexes[0],
+            }),
+            Proposal::Remove(RemoveProposal {
+                to_remove: test_leaf_node_indexes[1],
+            }),
+        ];
+
+        let res = cache
+            .resolve_for_commit_default(
+                Sender::NewMemberCommit,
+                proposals
+                    .into_iter()
+                    .map(|p| ProposalOrRef::Proposal(Box::new(p)))
+                    .collect(),
+                Some(&test_node().await),
+                &group_extensions,
+                &BasicIdentityProvider,
+                &cipher_suite_provider,
+                &public_tree,
+                &AlwaysFoundPskStorage,
+                pass_through_rules(),
+            )
+            .await;
+
+        assert_matches!(res, Err(MlsError::ExternalCommitWithMoreThanOneRemove));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn new_member_remove_proposal_invalid_credential() {
+        let cache = make_proposal_cache();
+        let cipher_suite_provider = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+        let kem_output = vec![0; cipher_suite_provider.kdf_extract_size()];
+        let group = test_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await;
+        let group_extensions = group.group.context().extensions.clone();
+        let mut public_tree = group.group.state.public_tree;
+
+        let node = get_basic_test_node(TEST_CIPHER_SUITE, "bar").await;
+
+        let test_leaf_nodes = vec![node];
+
+        let test_leaf_node_indexes = public_tree
+            .add_leaves(
+                test_leaf_nodes,
+                &BasicIdentityProvider,
+                &cipher_suite_provider,
+            )
+            .await
+            .unwrap();
+
+        let proposals = vec![
+            Proposal::ExternalInit(ExternalInit { kem_output }),
+            Proposal::Remove(RemoveProposal {
+                to_remove: test_leaf_node_indexes[0],
+            }),
+        ];
+
+        let res = cache
+            .resolve_for_commit_default(
+                Sender::NewMemberCommit,
+                proposals
+                    .into_iter()
+                    .map(|p| ProposalOrRef::Proposal(Box::new(p)))
+                    .collect(),
+                Some(&test_node().await),
+                &group_extensions,
+                &BasicIdentityProvider,
+                &cipher_suite_provider,
+                &public_tree,
+                &AlwaysFoundPskStorage,
+                pass_through_rules(),
+            )
+            .await;
+
+        assert_matches!(res, Err(MlsError::ExternalCommitRemovesOtherIdentity));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn new_member_remove_proposal_valid_credential() {
+        let cache = make_proposal_cache();
+        let cipher_suite_provider = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+        let kem_output = vec![0; cipher_suite_provider.kdf_extract_size()];
+        let group = test_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await;
+        let group_extensions = group.group.context().extensions.clone();
+        let mut public_tree = group.group.state.public_tree;
+
+        let node = get_basic_test_node(TEST_CIPHER_SUITE, "foo").await;
+
+        let test_leaf_nodes = vec![node];
+
+        let test_leaf_node_indexes = public_tree
+            .add_leaves(
+                test_leaf_nodes,
+                &BasicIdentityProvider,
+                &cipher_suite_provider,
+            )
+            .await
+            .unwrap();
+
+        let proposals = vec![
+            Proposal::ExternalInit(ExternalInit { kem_output }),
+            Proposal::Remove(RemoveProposal {
+                to_remove: test_leaf_node_indexes[0],
+            }),
+        ];
+
+        let res = cache
+            .resolve_for_commit_default(
+                Sender::NewMemberCommit,
+                proposals
+                    .into_iter()
+                    .map(|p| ProposalOrRef::Proposal(Box::new(p)))
+                    .collect(),
+                Some(&test_node().await),
+                &group_extensions,
+                &BasicIdentityProvider,
+                &cipher_suite_provider,
+                &public_tree,
+                &AlwaysFoundPskStorage,
+                pass_through_rules(),
+            )
+            .await;
+
+        assert_matches!(res, Ok(_));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn new_member_cannot_commit_update_proposal() {
+        let res = new_member_commits_proposal(Proposal::Update(UpdateProposal {
+            leaf_node: get_basic_test_node(TEST_CIPHER_SUITE, "foo").await,
+        }))
+        .await;
+
+        assert_matches!(
+            res,
+            Err(MlsError::InvalidProposalTypeInExternalCommit(
+                ProposalType::UPDATE
+            ))
+        );
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn new_member_cannot_commit_group_extensions_proposal() {
+        let res =
+            new_member_commits_proposal(Proposal::GroupContextExtensions(ExtensionList::new()))
+                .await;
+
+        assert_matches!(
+            res,
+            Err(MlsError::InvalidProposalTypeInExternalCommit(
+                ProposalType::GROUP_CONTEXT_EXTENSIONS,
+            ))
+        );
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn new_member_cannot_commit_reinit_proposal() {
+        let res = new_member_commits_proposal(Proposal::ReInit(ReInitProposal {
+            group_id: b"foo".to_vec(),
+            version: TEST_PROTOCOL_VERSION,
+            cipher_suite: TEST_CIPHER_SUITE,
+            extensions: ExtensionList::new(),
+        }))
+        .await;
+
+        assert_matches!(
+            res,
+            Err(MlsError::InvalidProposalTypeInExternalCommit(
+                ProposalType::RE_INIT
+            ))
+        );
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn new_member_commit_must_contain_an_external_init_proposal() {
+        let cache = make_proposal_cache();
+        let cipher_suite_provider = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+        let group = test_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await;
+        let public_tree = &group.group.state.public_tree;
+
+        let res = cache
+            .resolve_for_commit_default(
+                Sender::NewMemberCommit,
+                Vec::new(),
+                Some(&test_node().await),
+                &group.group.context().extensions,
+                &BasicIdentityProvider,
+                &cipher_suite_provider,
+                public_tree,
+                &AlwaysFoundPskStorage,
+                pass_through_rules(),
+            )
+            .await;
+
+        assert_matches!(
+            res,
+            Err(MlsError::ExternalCommitMustHaveExactlyOneExternalInit)
+        );
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_path_update_required_empty() {
+        let cache = make_proposal_cache();
+        let cipher_suite_provider = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+
+        let mut tree = TreeKemPublic::new();
+        add_member(&mut tree, "alice").await;
+        add_member(&mut tree, "bob").await;
+
+        let effects = cache
+            .prepare_commit_default(
+                Sender::Member(test_sender()),
+                vec![],
+                &get_test_group_context(1, TEST_CIPHER_SUITE).await,
+                &BasicIdentityProvider,
+                &cipher_suite_provider,
+                &tree,
+                None,
+                &AlwaysFoundPskStorage,
+                pass_through_rules(),
+            )
+            .await
+            .unwrap();
+
+        assert!(path_update_required(&effects.applied_proposals))
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_path_update_required_updates() {
+        let mut cache = make_proposal_cache();
+        let update = Proposal::Update(make_update_proposal("bar").await);
+        let cipher_suite_provider = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+
+        cache.insert(
+            make_proposal_ref(&update, LeafIndex(2)).await,
+            update,
+            Sender::Member(2),
+        );
+
+        let mut tree = TreeKemPublic::new();
+        add_member(&mut tree, "alice").await;
+        add_member(&mut tree, "bob").await;
+        add_member(&mut tree, "carol").await;
+
+        let effects = cache
+            .prepare_commit_default(
+                Sender::Member(test_sender()),
+                Vec::new(),
+                &get_test_group_context(1, TEST_CIPHER_SUITE).await,
+                &BasicIdentityProvider,
+                &cipher_suite_provider,
+                &tree,
+                None,
+                &AlwaysFoundPskStorage,
+                pass_through_rules(),
+            )
+            .await
+            .unwrap();
+
+        assert!(path_update_required(&effects.applied_proposals))
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_path_update_required_removes() {
+        let cache = make_proposal_cache();
+        let cipher_suite_provider = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+
+        let (alice_leaf, alice_secret, _) =
+            get_basic_test_node_sig_key(TEST_CIPHER_SUITE, "alice").await;
+        let alice = 0;
+
+        let (mut tree, _) = TreeKemPublic::derive(
+            alice_leaf,
+            alice_secret,
+            &BasicIdentityProvider,
+            &Default::default(),
+        )
+        .await
+        .unwrap();
+
+        let bob_node = get_basic_test_node(TEST_CIPHER_SUITE, "bob").await;
+
+        let bob = tree
+            .add_leaves(
+                vec![bob_node],
+                &BasicIdentityProvider,
+                &cipher_suite_provider,
+            )
+            .await
+            .unwrap()[0];
+
+        let remove = Proposal::Remove(RemoveProposal { to_remove: bob });
+
+        let effects = cache
+            .prepare_commit_default(
+                Sender::Member(alice),
+                vec![remove],
+                &get_test_group_context(1, TEST_CIPHER_SUITE).await,
+                &BasicIdentityProvider,
+                &cipher_suite_provider,
+                &tree,
+                None,
+                &AlwaysFoundPskStorage,
+                pass_through_rules(),
+            )
+            .await
+            .unwrap();
+
+        assert!(path_update_required(&effects.applied_proposals))
+    }
+
+    #[cfg(feature = "psk")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_path_update_not_required() {
+        let (alice, tree) = new_tree("alice").await;
+        let cipher_suite_provider = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+        let cache = make_proposal_cache();
+
+        let psk = Proposal::Psk(PreSharedKeyProposal {
+            psk: PreSharedKeyID::new(
+                JustPreSharedKeyID::External(ExternalPskId::new(vec![])),
+                &test_cipher_suite_provider(TEST_CIPHER_SUITE),
+            )
+            .unwrap(),
+        });
+
+        let add = Proposal::Add(Box::new(AddProposal {
+            key_package: test_key_package(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, "bob").await,
+        }));
+
+        let effects = cache
+            .prepare_commit_default(
+                Sender::Member(*alice),
+                vec![psk, add],
+                &get_test_group_context(1, TEST_CIPHER_SUITE).await,
+                &BasicIdentityProvider,
+                &cipher_suite_provider,
+                &tree,
+                None,
+                &AlwaysFoundPskStorage,
+                pass_through_rules(),
+            )
+            .await
+            .unwrap();
+
+        assert!(!path_update_required(&effects.applied_proposals))
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn path_update_is_not_required_for_re_init() {
+        let cipher_suite_provider = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+        let (alice, tree) = new_tree("alice").await;
+        let cache = make_proposal_cache();
+
+        let reinit = Proposal::ReInit(ReInitProposal {
+            group_id: vec![],
+            version: TEST_PROTOCOL_VERSION,
+            cipher_suite: TEST_CIPHER_SUITE,
+            extensions: Default::default(),
+        });
+
+        let effects = cache
+            .prepare_commit_default(
+                Sender::Member(*alice),
+                vec![reinit],
+                &get_test_group_context(1, TEST_CIPHER_SUITE).await,
+                &BasicIdentityProvider,
+                &cipher_suite_provider,
+                &tree,
+                None,
+                &AlwaysFoundPskStorage,
+                pass_through_rules(),
+            )
+            .await
+            .unwrap();
+
+        assert!(!path_update_required(&effects.applied_proposals))
+    }
+
+    #[derive(Debug)]
+    struct CommitSender<'a, C, F, P, CSP> {
+        cipher_suite_provider: CSP,
+        tree: &'a TreeKemPublic,
+        sender: LeafIndex,
+        cache: ProposalCache,
+        additional_proposals: Vec<Proposal>,
+        identity_provider: C,
+        user_rules: F,
+        psk_storage: P,
+    }
+
+    impl<'a, CSP>
+        CommitSender<'a, BasicWithCustomProvider, DefaultMlsRules, AlwaysFoundPskStorage, CSP>
+    {
+        fn new(tree: &'a TreeKemPublic, sender: LeafIndex, cipher_suite_provider: CSP) -> Self {
+            Self {
+                tree,
+                sender,
+                cache: make_proposal_cache(),
+                additional_proposals: Vec::new(),
+                identity_provider: BasicWithCustomProvider::new(BasicIdentityProvider::new()),
+                user_rules: pass_through_rules(),
+                psk_storage: AlwaysFoundPskStorage,
+                cipher_suite_provider,
+            }
+        }
+    }
+
+    impl<'a, C, F, P, CSP> CommitSender<'a, C, F, P, CSP>
+    where
+        C: IdentityProvider,
+        F: MlsRules,
+        P: PreSharedKeyStorage,
+        CSP: CipherSuiteProvider,
+    {
+        #[cfg(feature = "by_ref_proposal")]
+        fn with_identity_provider<V>(self, identity_provider: V) -> CommitSender<'a, V, F, P, CSP>
+        where
+            V: IdentityProvider,
+        {
+            CommitSender {
+                identity_provider,
+                cipher_suite_provider: self.cipher_suite_provider,
+                tree: self.tree,
+                sender: self.sender,
+                cache: self.cache,
+                additional_proposals: self.additional_proposals,
+                user_rules: self.user_rules,
+                psk_storage: self.psk_storage,
+            }
+        }
+
+        fn cache<S>(mut self, r: ProposalRef, p: Proposal, proposer: S) -> Self
+        where
+            S: Into<Sender>,
+        {
+            self.cache.insert(r, p, proposer.into());
+            self
+        }
+
+        fn with_additional<I>(mut self, proposals: I) -> Self
+        where
+            I: IntoIterator<Item = Proposal>,
+        {
+            self.additional_proposals.extend(proposals);
+            self
+        }
+
+        fn with_user_rules<G>(self, f: G) -> CommitSender<'a, C, G, P, CSP>
+        where
+            G: MlsRules,
+        {
+            CommitSender {
+                tree: self.tree,
+                sender: self.sender,
+                cache: self.cache,
+                additional_proposals: self.additional_proposals,
+                identity_provider: self.identity_provider,
+                user_rules: f,
+                psk_storage: self.psk_storage,
+                cipher_suite_provider: self.cipher_suite_provider,
+            }
+        }
+
+        fn with_psk_storage<V>(self, v: V) -> CommitSender<'a, C, F, V, CSP>
+        where
+            V: PreSharedKeyStorage,
+        {
+            CommitSender {
+                tree: self.tree,
+                sender: self.sender,
+                cache: self.cache,
+                additional_proposals: self.additional_proposals,
+                identity_provider: self.identity_provider,
+                user_rules: self.user_rules,
+                psk_storage: v,
+                cipher_suite_provider: self.cipher_suite_provider,
+            }
+        }
+
+        #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+        async fn send(&self) -> Result<(Vec<ProposalOrRef>, ProvisionalState), MlsError> {
+            let state = self
+                .cache
+                .prepare_commit_default(
+                    Sender::Member(*self.sender),
+                    self.additional_proposals.clone(),
+                    &get_test_group_context(1, TEST_CIPHER_SUITE).await,
+                    &self.identity_provider,
+                    &self.cipher_suite_provider,
+                    self.tree,
+                    None,
+                    &self.psk_storage,
+                    &self.user_rules,
+                )
+                .await?;
+
+            let proposals = state.applied_proposals.clone().into_proposals_or_refs();
+
+            Ok((proposals, state))
+        }
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn key_package_with_invalid_signature() -> KeyPackage {
+        let mut kp = test_key_package(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, "mallory").await;
+        kp.signature.clear();
+        kp
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn key_package_with_public_key(key: crypto::HpkePublicKey) -> KeyPackage {
+        let cs = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+
+        let (mut key_package, signer) =
+            test_key_package_with_signer(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, "test").await;
+
+        key_package.leaf_node.public_key = key;
+
+        key_package
+            .leaf_node
+            .sign(
+                &cs,
+                &signer,
+                &LeafNodeSigningContext {
+                    group_id: None,
+                    leaf_index: None,
+                },
+            )
+            .await
+            .unwrap();
+
+        key_package.sign(&cs, &signer, &()).await.unwrap();
+
+        key_package
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn receiving_add_with_invalid_key_package_fails() {
+        let (alice, tree) = new_tree("alice").await;
+
+        let res = CommitReceiver::new(
+            &tree,
+            alice,
+            alice,
+            test_cipher_suite_provider(TEST_CIPHER_SUITE),
+        )
+        .receive([Proposal::Add(Box::new(AddProposal {
+            key_package: key_package_with_invalid_signature().await,
+        }))])
+        .await;
+
+        assert_matches!(res, Err(MlsError::InvalidSignature));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn sending_additional_add_with_invalid_key_package_fails() {
+        let (alice, tree) = new_tree("alice").await;
+
+        let res = CommitSender::new(&tree, alice, test_cipher_suite_provider(TEST_CIPHER_SUITE))
+            .with_additional([Proposal::Add(Box::new(AddProposal {
+                key_package: key_package_with_invalid_signature().await,
+            }))])
+            .send()
+            .await;
+
+        assert_matches!(res, Err(MlsError::InvalidSignature));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn sending_add_with_invalid_key_package_filters_it_out() {
+        let (alice, tree) = new_tree("alice").await;
+
+        let proposal = Proposal::Add(Box::new(AddProposal {
+            key_package: key_package_with_invalid_signature().await,
+        }));
+
+        let proposal_info = make_proposal_info(&proposal, alice).await;
+
+        let processed_proposals =
+            CommitSender::new(&tree, alice, test_cipher_suite_provider(TEST_CIPHER_SUITE))
+                .cache(
+                    proposal_info.proposal_ref().unwrap().clone(),
+                    proposal.clone(),
+                    alice,
+                )
+                .send()
+                .await
+                .unwrap();
+
+        assert_eq!(processed_proposals.0, Vec::new());
+
+        #[cfg(feature = "state_update")]
+        assert_eq!(processed_proposals.1.unused_proposals, vec![proposal_info]);
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn sending_add_with_hpke_key_of_another_member_fails() {
+        let (alice, tree) = new_tree("alice").await;
+
+        let res = CommitSender::new(&tree, alice, test_cipher_suite_provider(TEST_CIPHER_SUITE))
+            .with_additional([Proposal::Add(Box::new(AddProposal {
+                key_package: key_package_with_public_key(
+                    tree.get_leaf_node(alice).unwrap().public_key.clone(),
+                )
+                .await,
+            }))])
+            .send()
+            .await;
+
+        assert_matches!(res, Err(MlsError::DuplicateLeafData(_)));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn sending_add_with_hpke_key_of_another_member_filters_it_out() {
+        let (alice, tree) = new_tree("alice").await;
+
+        let proposal = Proposal::Add(Box::new(AddProposal {
+            key_package: key_package_with_public_key(
+                tree.get_leaf_node(alice).unwrap().public_key.clone(),
+            )
+            .await,
+        }));
+
+        let proposal_info = make_proposal_info(&proposal, alice).await;
+
+        let processed_proposals =
+            CommitSender::new(&tree, alice, test_cipher_suite_provider(TEST_CIPHER_SUITE))
+                .cache(
+                    proposal_info.proposal_ref().unwrap().clone(),
+                    proposal.clone(),
+                    alice,
+                )
+                .send()
+                .await
+                .unwrap();
+
+        assert_eq!(processed_proposals.0, Vec::new());
+
+        #[cfg(feature = "state_update")]
+        assert_eq!(processed_proposals.1.unused_proposals, vec![proposal_info]);
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn receiving_update_with_invalid_leaf_node_fails() {
+        let (alice, mut tree) = new_tree("alice").await;
+        let bob = add_member(&mut tree, "bob").await;
+
+        let proposal = Proposal::Update(UpdateProposal {
+            leaf_node: get_basic_test_node(TEST_CIPHER_SUITE, "alice").await,
+        });
+
+        let proposal_ref = make_proposal_ref(&proposal, bob).await;
+
+        let res = CommitReceiver::new(
+            &tree,
+            alice,
+            bob,
+            test_cipher_suite_provider(TEST_CIPHER_SUITE),
+        )
+        .cache(proposal_ref.clone(), proposal, bob)
+        .receive([proposal_ref])
+        .await;
+
+        assert_matches!(res, Err(MlsError::InvalidLeafNodeSource));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn sending_update_with_invalid_leaf_node_filters_it_out() {
+        let (alice, mut tree) = new_tree("alice").await;
+        let bob = add_member(&mut tree, "bob").await;
+
+        let proposal = Proposal::Update(UpdateProposal {
+            leaf_node: get_basic_test_node(TEST_CIPHER_SUITE, "alice").await,
+        });
+
+        let proposal_info = make_proposal_info(&proposal, bob).await;
+
+        let processed_proposals =
+            CommitSender::new(&tree, alice, test_cipher_suite_provider(TEST_CIPHER_SUITE))
+                .cache(proposal_info.proposal_ref().unwrap().clone(), proposal, bob)
+                .send()
+                .await
+                .unwrap();
+
+        assert_eq!(processed_proposals.0, Vec::new());
+
+        #[cfg(feature = "state_update")]
+        assert_eq!(processed_proposals.1.unused_proposals, vec![proposal_info]);
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn receiving_remove_with_invalid_index_fails() {
+        let (alice, tree) = new_tree("alice").await;
+
+        let res = CommitReceiver::new(
+            &tree,
+            alice,
+            alice,
+            test_cipher_suite_provider(TEST_CIPHER_SUITE),
+        )
+        .receive([Proposal::Remove(RemoveProposal {
+            to_remove: LeafIndex(10),
+        })])
+        .await;
+
+        assert_matches!(res, Err(MlsError::InvalidNodeIndex(20)));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn sending_additional_remove_with_invalid_index_fails() {
+        let (alice, tree) = new_tree("alice").await;
+
+        let res = CommitSender::new(&tree, alice, test_cipher_suite_provider(TEST_CIPHER_SUITE))
+            .with_additional([Proposal::Remove(RemoveProposal {
+                to_remove: LeafIndex(10),
+            })])
+            .send()
+            .await;
+
+        assert_matches!(res, Err(MlsError::InvalidNodeIndex(20)));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn sending_remove_with_invalid_index_filters_it_out() {
+        let (alice, tree) = new_tree("alice").await;
+
+        let proposal = Proposal::Remove(RemoveProposal {
+            to_remove: LeafIndex(10),
+        });
+
+        let proposal_info = make_proposal_info(&proposal, alice).await;
+
+        let processed_proposals =
+            CommitSender::new(&tree, alice, test_cipher_suite_provider(TEST_CIPHER_SUITE))
+                .cache(
+                    proposal_info.proposal_ref().unwrap().clone(),
+                    proposal.clone(),
+                    alice,
+                )
+                .send()
+                .await
+                .unwrap();
+
+        assert_eq!(processed_proposals.0, Vec::new());
+
+        #[cfg(feature = "state_update")]
+        assert_eq!(processed_proposals.1.unused_proposals, vec![proposal_info]);
+    }
+
+    #[cfg(feature = "psk")]
+    fn make_external_psk(id: &[u8], nonce: PskNonce) -> PreSharedKeyProposal {
+        PreSharedKeyProposal {
+            psk: PreSharedKeyID {
+                key_id: JustPreSharedKeyID::External(ExternalPskId::new(id.to_vec())),
+                psk_nonce: nonce,
+            },
+        }
+    }
+
+    #[cfg(feature = "psk")]
+    fn new_external_psk(id: &[u8]) -> PreSharedKeyProposal {
+        make_external_psk(
+            id,
+            PskNonce::random(&test_cipher_suite_provider(TEST_CIPHER_SUITE)).unwrap(),
+        )
+    }
+
+    #[cfg(feature = "psk")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn receiving_psk_with_invalid_nonce_fails() {
+        let invalid_nonce = PskNonce(vec![0, 1, 2]);
+        let (alice, tree) = new_tree("alice").await;
+
+        let res = CommitReceiver::new(
+            &tree,
+            alice,
+            alice,
+            test_cipher_suite_provider(TEST_CIPHER_SUITE),
+        )
+        .receive([Proposal::Psk(make_external_psk(
+            b"foo",
+            invalid_nonce.clone(),
+        ))])
+        .await;
+
+        assert_matches!(res, Err(MlsError::InvalidPskNonceLength,));
+    }
+
+    #[cfg(feature = "psk")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn sending_additional_psk_with_invalid_nonce_fails() {
+        let invalid_nonce = PskNonce(vec![0, 1, 2]);
+        let (alice, tree) = new_tree("alice").await;
+
+        let res = CommitSender::new(&tree, alice, test_cipher_suite_provider(TEST_CIPHER_SUITE))
+            .with_additional([Proposal::Psk(make_external_psk(
+                b"foo",
+                invalid_nonce.clone(),
+            ))])
+            .send()
+            .await;
+
+        assert_matches!(res, Err(MlsError::InvalidPskNonceLength));
+    }
+
+    #[cfg(feature = "psk")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn sending_psk_with_invalid_nonce_filters_it_out() {
+        let invalid_nonce = PskNonce(vec![0, 1, 2]);
+        let (alice, tree) = new_tree("alice").await;
+        let proposal = Proposal::Psk(make_external_psk(b"foo", invalid_nonce));
+
+        let proposal_info = make_proposal_info(&proposal, alice).await;
+
+        let processed_proposals =
+            CommitSender::new(&tree, alice, test_cipher_suite_provider(TEST_CIPHER_SUITE))
+                .cache(
+                    proposal_info.proposal_ref().unwrap().clone(),
+                    proposal.clone(),
+                    alice,
+                )
+                .send()
+                .await
+                .unwrap();
+
+        assert_eq!(processed_proposals.0, Vec::new());
+
+        #[cfg(feature = "state_update")]
+        assert_eq!(processed_proposals.1.unused_proposals, vec![proposal_info]);
+    }
+
+    #[cfg(feature = "psk")]
+    fn make_resumption_psk(usage: ResumptionPSKUsage) -> PreSharedKeyProposal {
+        PreSharedKeyProposal {
+            psk: PreSharedKeyID {
+                key_id: JustPreSharedKeyID::Resumption(ResumptionPsk {
+                    usage,
+                    psk_group_id: PskGroupId(TEST_GROUP.to_vec()),
+                    psk_epoch: 1,
+                }),
+                psk_nonce: PskNonce::random(&test_cipher_suite_provider(TEST_CIPHER_SUITE))
+                    .unwrap(),
+            },
+        }
+    }
+
+    #[cfg(feature = "psk")]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn receiving_resumption_psk_with_bad_usage_fails(usage: ResumptionPSKUsage) {
+        let (alice, tree) = new_tree("alice").await;
+
+        let res = CommitReceiver::new(
+            &tree,
+            alice,
+            alice,
+            test_cipher_suite_provider(TEST_CIPHER_SUITE),
+        )
+        .receive([Proposal::Psk(make_resumption_psk(usage))])
+        .await;
+
+        assert_matches!(res, Err(MlsError::InvalidTypeOrUsageInPreSharedKeyProposal));
+    }
+
+    #[cfg(feature = "psk")]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn sending_additional_resumption_psk_with_bad_usage_fails(usage: ResumptionPSKUsage) {
+        let (alice, tree) = new_tree("alice").await;
+
+        let res = CommitSender::new(&tree, alice, test_cipher_suite_provider(TEST_CIPHER_SUITE))
+            .with_additional([Proposal::Psk(make_resumption_psk(usage))])
+            .send()
+            .await;
+
+        assert_matches!(res, Err(MlsError::InvalidTypeOrUsageInPreSharedKeyProposal));
+    }
+
+    #[cfg(feature = "psk")]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn sending_resumption_psk_with_bad_usage_filters_it_out(usage: ResumptionPSKUsage) {
+        let (alice, tree) = new_tree("alice").await;
+        let proposal = Proposal::Psk(make_resumption_psk(usage));
+        let proposal_info = make_proposal_info(&proposal, alice).await;
+
+        let processed_proposals =
+            CommitSender::new(&tree, alice, test_cipher_suite_provider(TEST_CIPHER_SUITE))
+                .cache(
+                    proposal_info.proposal_ref().unwrap().clone(),
+                    proposal.clone(),
+                    alice,
+                )
+                .send()
+                .await
+                .unwrap();
+
+        assert_eq!(processed_proposals.0, Vec::new());
+
+        #[cfg(feature = "state_update")]
+        assert_eq!(processed_proposals.1.unused_proposals, vec![proposal_info]);
+    }
+
+    #[cfg(feature = "psk")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn receiving_resumption_psk_with_reinit_usage_fails() {
+        receiving_resumption_psk_with_bad_usage_fails(ResumptionPSKUsage::Reinit).await;
+    }
+
+    #[cfg(feature = "psk")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn sending_additional_resumption_psk_with_reinit_usage_fails() {
+        sending_additional_resumption_psk_with_bad_usage_fails(ResumptionPSKUsage::Reinit).await;
+    }
+
+    #[cfg(feature = "psk")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn sending_resumption_psk_with_reinit_usage_filters_it_out() {
+        sending_resumption_psk_with_bad_usage_filters_it_out(ResumptionPSKUsage::Reinit).await;
+    }
+
+    #[cfg(feature = "psk")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn receiving_resumption_psk_with_branch_usage_fails() {
+        receiving_resumption_psk_with_bad_usage_fails(ResumptionPSKUsage::Branch).await;
+    }
+
+    #[cfg(feature = "psk")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn sending_additional_resumption_psk_with_branch_usage_fails() {
+        sending_additional_resumption_psk_with_bad_usage_fails(ResumptionPSKUsage::Branch).await;
+    }
+
+    #[cfg(feature = "psk")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn sending_resumption_psk_with_branch_usage_filters_it_out() {
+        sending_resumption_psk_with_bad_usage_filters_it_out(ResumptionPSKUsage::Branch).await;
+    }
+
+    fn make_reinit(version: ProtocolVersion) -> ReInitProposal {
+        ReInitProposal {
+            group_id: TEST_GROUP.to_vec(),
+            version,
+            cipher_suite: TEST_CIPHER_SUITE,
+            extensions: ExtensionList::new(),
+        }
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn receiving_reinit_downgrading_version_fails() {
+        let smaller_protocol_version = ProtocolVersion::from(0);
+        let (alice, tree) = new_tree("alice").await;
+
+        let res = CommitReceiver::new(
+            &tree,
+            alice,
+            alice,
+            test_cipher_suite_provider(TEST_CIPHER_SUITE),
+        )
+        .receive([Proposal::ReInit(make_reinit(smaller_protocol_version))])
+        .await;
+
+        assert_matches!(res, Err(MlsError::InvalidProtocolVersionInReInit));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn sending_additional_reinit_downgrading_version_fails() {
+        let smaller_protocol_version = ProtocolVersion::from(0);
+        let (alice, tree) = new_tree("alice").await;
+
+        let res = CommitSender::new(&tree, alice, test_cipher_suite_provider(TEST_CIPHER_SUITE))
+            .with_additional([Proposal::ReInit(make_reinit(smaller_protocol_version))])
+            .send()
+            .await;
+
+        assert_matches!(res, Err(MlsError::InvalidProtocolVersionInReInit));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn sending_reinit_downgrading_version_filters_it_out() {
+        let smaller_protocol_version = ProtocolVersion::from(0);
+        let (alice, tree) = new_tree("alice").await;
+        let proposal = Proposal::ReInit(make_reinit(smaller_protocol_version));
+        let proposal_info = make_proposal_info(&proposal, alice).await;
+
+        let processed_proposals =
+            CommitSender::new(&tree, alice, test_cipher_suite_provider(TEST_CIPHER_SUITE))
+                .cache(
+                    proposal_info.proposal_ref().unwrap().clone(),
+                    proposal.clone(),
+                    alice,
+                )
+                .send()
+                .await
+                .unwrap();
+
+        assert_eq!(processed_proposals.0, Vec::new());
+
+        #[cfg(feature = "state_update")]
+        assert_eq!(processed_proposals.1.unused_proposals, vec![proposal_info]);
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn receiving_update_for_committer_fails() {
+        let (alice, tree) = new_tree("alice").await;
+        let update = Proposal::Update(make_update_proposal("alice").await);
+        let update_ref = make_proposal_ref(&update, alice).await;
+
+        let res = CommitReceiver::new(
+            &tree,
+            alice,
+            alice,
+            test_cipher_suite_provider(TEST_CIPHER_SUITE),
+        )
+        .cache(update_ref.clone(), update, alice)
+        .receive([update_ref])
+        .await;
+
+        assert_matches!(res, Err(MlsError::InvalidCommitSelfUpdate));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn sending_additional_update_for_committer_fails() {
+        let (alice, tree) = new_tree("alice").await;
+
+        let res = CommitSender::new(&tree, alice, test_cipher_suite_provider(TEST_CIPHER_SUITE))
+            .with_additional([Proposal::Update(make_update_proposal("alice").await)])
+            .send()
+            .await;
+
+        assert_matches!(res, Err(MlsError::InvalidProposalTypeForSender));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn sending_update_for_committer_filters_it_out() {
+        let (alice, tree) = new_tree("alice").await;
+        let proposal = Proposal::Update(make_update_proposal("alice").await);
+        let proposal_info = make_proposal_info(&proposal, alice).await;
+
+        let processed_proposals =
+            CommitSender::new(&tree, alice, test_cipher_suite_provider(TEST_CIPHER_SUITE))
+                .cache(
+                    proposal_info.proposal_ref().unwrap().clone(),
+                    proposal.clone(),
+                    alice,
+                )
+                .send()
+                .await
+                .unwrap();
+
+        assert_eq!(processed_proposals.0, Vec::new());
+
+        #[cfg(feature = "state_update")]
+        assert_eq!(processed_proposals.1.unused_proposals, vec![proposal_info]);
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn receiving_remove_for_committer_fails() {
+        let (alice, tree) = new_tree("alice").await;
+
+        let res = CommitReceiver::new(
+            &tree,
+            alice,
+            alice,
+            test_cipher_suite_provider(TEST_CIPHER_SUITE),
+        )
+        .receive([Proposal::Remove(RemoveProposal { to_remove: alice })])
+        .await;
+
+        assert_matches!(res, Err(MlsError::CommitterSelfRemoval));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn sending_additional_remove_for_committer_fails() {
+        let (alice, tree) = new_tree("alice").await;
+
+        let res = CommitSender::new(&tree, alice, test_cipher_suite_provider(TEST_CIPHER_SUITE))
+            .with_additional([Proposal::Remove(RemoveProposal { to_remove: alice })])
+            .send()
+            .await;
+
+        assert_matches!(res, Err(MlsError::CommitterSelfRemoval));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn sending_remove_for_committer_filters_it_out() {
+        let (alice, tree) = new_tree("alice").await;
+        let proposal = Proposal::Remove(RemoveProposal { to_remove: alice });
+        let proposal_info = make_proposal_info(&proposal, alice).await;
+
+        let processed_proposals =
+            CommitSender::new(&tree, alice, test_cipher_suite_provider(TEST_CIPHER_SUITE))
+                .cache(
+                    proposal_info.proposal_ref().unwrap().clone(),
+                    proposal.clone(),
+                    alice,
+                )
+                .send()
+                .await
+                .unwrap();
+
+        assert_eq!(processed_proposals.0, Vec::new());
+
+        #[cfg(feature = "state_update")]
+        assert_eq!(processed_proposals.1.unused_proposals, vec![proposal_info]);
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn receiving_update_and_remove_for_same_leaf_fails() {
+        let (alice, mut tree) = new_tree("alice").await;
+        let bob = add_member(&mut tree, "bob").await;
+
+        let update = Proposal::Update(make_update_proposal("bob").await);
+        let update_ref = make_proposal_ref(&update, bob).await;
+
+        let remove = Proposal::Remove(RemoveProposal { to_remove: bob });
+        let remove_ref = make_proposal_ref(&remove, bob).await;
+
+        let res = CommitReceiver::new(
+            &tree,
+            alice,
+            alice,
+            test_cipher_suite_provider(TEST_CIPHER_SUITE),
+        )
+        .cache(update_ref.clone(), update, bob)
+        .cache(remove_ref.clone(), remove, bob)
+        .receive([update_ref, remove_ref])
+        .await;
+
+        assert_matches!(res, Err(MlsError::UpdatingNonExistingMember));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn sending_update_and_remove_for_same_leaf_filters_update_out() {
+        let (alice, mut tree) = new_tree("alice").await;
+        let bob = add_member(&mut tree, "bob").await;
+
+        let update = Proposal::Update(make_update_proposal("bob").await);
+        let update_info = make_proposal_info(&update, alice).await;
+
+        let remove = Proposal::Remove(RemoveProposal { to_remove: bob });
+        let remove_ref = make_proposal_ref(&remove, alice).await;
+
+        let processed_proposals =
+            CommitSender::new(&tree, alice, test_cipher_suite_provider(TEST_CIPHER_SUITE))
+                .cache(
+                    update_info.proposal_ref().unwrap().clone(),
+                    update.clone(),
+                    alice,
+                )
+                .cache(remove_ref.clone(), remove, alice)
+                .send()
+                .await
+                .unwrap();
+
+        assert_eq!(processed_proposals.0, vec![remove_ref.into()]);
+
+        #[cfg(feature = "state_update")]
+        assert_eq!(processed_proposals.1.unused_proposals, vec![update_info]);
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn make_add_proposal() -> Box<AddProposal> {
+        Box::new(AddProposal {
+            key_package: test_key_package(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, "frank").await,
+        })
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn receiving_add_proposals_for_same_client_fails() {
+        let (alice, tree) = new_tree("alice").await;
+
+        let res = CommitReceiver::new(
+            &tree,
+            alice,
+            alice,
+            test_cipher_suite_provider(TEST_CIPHER_SUITE),
+        )
+        .receive([
+            Proposal::Add(make_add_proposal().await),
+            Proposal::Add(make_add_proposal().await),
+        ])
+        .await;
+
+        assert_matches!(res, Err(MlsError::DuplicateLeafData(1)));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn sending_additional_add_proposals_for_same_client_fails() {
+        let (alice, tree) = new_tree("alice").await;
+
+        let res = CommitSender::new(&tree, alice, test_cipher_suite_provider(TEST_CIPHER_SUITE))
+            .with_additional([
+                Proposal::Add(make_add_proposal().await),
+                Proposal::Add(make_add_proposal().await),
+            ])
+            .send()
+            .await;
+
+        assert_matches!(res, Err(MlsError::DuplicateLeafData(1)));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn sending_add_proposals_for_same_client_keeps_only_one() {
+        let (alice, tree) = new_tree("alice").await;
+
+        let add_one = Proposal::Add(make_add_proposal().await);
+        let add_two = Proposal::Add(make_add_proposal().await);
+        let add_ref_one = make_proposal_ref(&add_one, alice).await;
+        let add_ref_two = make_proposal_ref(&add_two, alice).await;
+
+        let processed_proposals =
+            CommitSender::new(&tree, alice, test_cipher_suite_provider(TEST_CIPHER_SUITE))
+                .cache(add_ref_one.clone(), add_one.clone(), alice)
+                .cache(add_ref_two.clone(), add_two.clone(), alice)
+                .send()
+                .await
+                .unwrap();
+
+        let committed_add_ref = match &*processed_proposals.0 {
+            [ProposalOrRef::Reference(add_ref)] => add_ref,
+            _ => panic!("committed proposals list does not contain exactly one reference"),
+        };
+
+        let add_refs = [add_ref_one, add_ref_two];
+        assert!(add_refs.contains(committed_add_ref));
+
+        #[cfg(feature = "state_update")]
+        assert_matches!(
+            &*processed_proposals.1.unused_proposals,
+            [rejected_add_info] if committed_add_ref != rejected_add_info.proposal_ref().unwrap() && add_refs.contains(rejected_add_info.proposal_ref().unwrap())
+        );
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn receiving_update_for_different_identity_fails() {
+        let (alice, mut tree) = new_tree("alice").await;
+        let bob = add_member(&mut tree, "bob").await;
+
+        let update = Proposal::Update(make_update_proposal_custom("carol", 1).await);
+        let update_ref = make_proposal_ref(&update, bob).await;
+
+        let res = CommitReceiver::new(
+            &tree,
+            alice,
+            alice,
+            test_cipher_suite_provider(TEST_CIPHER_SUITE),
+        )
+        .cache(update_ref.clone(), update, bob)
+        .receive([update_ref])
+        .await;
+
+        assert_matches!(res, Err(MlsError::InvalidSuccessor));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn sending_update_for_different_identity_filters_it_out() {
+        let (alice, mut tree) = new_tree("alice").await;
+        let bob = add_member(&mut tree, "bob").await;
+
+        let update = Proposal::Update(make_update_proposal("carol").await);
+        let update_info = make_proposal_info(&update, bob).await;
+
+        let processed_proposals =
+            CommitSender::new(&tree, alice, test_cipher_suite_provider(TEST_CIPHER_SUITE))
+                .cache(update_info.proposal_ref().unwrap().clone(), update, bob)
+                .send()
+                .await
+                .unwrap();
+
+        assert_eq!(processed_proposals.0, Vec::new());
+
+        // Bob proposed the update, so it is not listed as rejected when Alice commits it because
+        // she didn't propose it.
+        #[cfg(feature = "state_update")]
+        assert_eq!(processed_proposals.1.unused_proposals, vec![update_info]);
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn receiving_add_for_same_client_as_existing_member_fails() {
+        let (alice, public_tree) = new_tree("alice").await;
+        let add = Proposal::Add(make_add_proposal().await);
+
+        let ProvisionalState { public_tree, .. } = CommitReceiver::new(
+            &public_tree,
+            alice,
+            alice,
+            test_cipher_suite_provider(TEST_CIPHER_SUITE),
+        )
+        .receive([add.clone()])
+        .await
+        .unwrap();
+
+        let res = CommitReceiver::new(
+            &public_tree,
+            alice,
+            alice,
+            test_cipher_suite_provider(TEST_CIPHER_SUITE),
+        )
+        .receive([add])
+        .await;
+
+        assert_matches!(res, Err(MlsError::DuplicateLeafData(1)));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn sending_additional_add_for_same_client_as_existing_member_fails() {
+        let (alice, public_tree) = new_tree("alice").await;
+        let add = Proposal::Add(make_add_proposal().await);
+
+        let ProvisionalState { public_tree, .. } = CommitReceiver::new(
+            &public_tree,
+            alice,
+            alice,
+            test_cipher_suite_provider(TEST_CIPHER_SUITE),
+        )
+        .receive([add.clone()])
+        .await
+        .unwrap();
+
+        let res = CommitSender::new(
+            &public_tree,
+            alice,
+            test_cipher_suite_provider(TEST_CIPHER_SUITE),
+        )
+        .with_additional([add])
+        .send()
+        .await;
+
+        assert_matches!(res, Err(MlsError::DuplicateLeafData(1)));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn sending_add_for_same_client_as_existing_member_filters_it_out() {
+        let (alice, public_tree) = new_tree("alice").await;
+        let add = Proposal::Add(make_add_proposal().await);
+
+        let ProvisionalState { public_tree, .. } = CommitReceiver::new(
+            &public_tree,
+            alice,
+            alice,
+            test_cipher_suite_provider(TEST_CIPHER_SUITE),
+        )
+        .receive([add.clone()])
+        .await
+        .unwrap();
+
+        let proposal_info = make_proposal_info(&add, alice).await;
+
+        let processed_proposals = CommitSender::new(
+            &public_tree,
+            alice,
+            test_cipher_suite_provider(TEST_CIPHER_SUITE),
+        )
+        .cache(
+            proposal_info.proposal_ref().unwrap().clone(),
+            add.clone(),
+            alice,
+        )
+        .send()
+        .await
+        .unwrap();
+
+        assert_eq!(processed_proposals.0, Vec::new());
+
+        #[cfg(feature = "state_update")]
+        assert_eq!(processed_proposals.1.unused_proposals, vec![proposal_info]);
+    }
+
+    #[cfg(feature = "psk")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn receiving_psk_proposals_with_same_psk_id_fails() {
+        let (alice, tree) = new_tree("alice").await;
+        let psk_proposal = Proposal::Psk(new_external_psk(b"foo"));
+
+        let res = CommitReceiver::new(
+            &tree,
+            alice,
+            alice,
+            test_cipher_suite_provider(TEST_CIPHER_SUITE),
+        )
+        .receive([psk_proposal.clone(), psk_proposal])
+        .await;
+
+        assert_matches!(res, Err(MlsError::DuplicatePskIds));
+    }
+
+    #[cfg(feature = "psk")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn sending_additional_psk_proposals_with_same_psk_id_fails() {
+        let (alice, tree) = new_tree("alice").await;
+        let psk_proposal = Proposal::Psk(new_external_psk(b"foo"));
+
+        let res = CommitSender::new(&tree, alice, test_cipher_suite_provider(TEST_CIPHER_SUITE))
+            .with_additional([psk_proposal.clone(), psk_proposal])
+            .send()
+            .await;
+
+        assert_matches!(res, Err(MlsError::DuplicatePskIds));
+    }
+
+    #[cfg(feature = "psk")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn sending_psk_proposals_with_same_psk_id_keeps_only_one() {
+        let (alice, mut tree) = new_tree("alice").await;
+        let bob = add_member(&mut tree, "bob").await;
+
+        let proposal = Proposal::Psk(new_external_psk(b"foo"));
+
+        let proposal_info = [
+            make_proposal_info(&proposal, alice).await,
+            make_proposal_info(&proposal, bob).await,
+        ];
+
+        let processed_proposals =
+            CommitSender::new(&tree, alice, test_cipher_suite_provider(TEST_CIPHER_SUITE))
+                .cache(
+                    proposal_info[0].proposal_ref().unwrap().clone(),
+                    proposal.clone(),
+                    alice,
+                )
+                .cache(
+                    proposal_info[1].proposal_ref().unwrap().clone(),
+                    proposal,
+                    bob,
+                )
+                .send()
+                .await
+                .unwrap();
+
+        let committed_info = match processed_proposals
+            .1
+            .applied_proposals
+            .clone()
+            .into_proposals()
+            .collect_vec()
+            .as_slice()
+        {
+            [r] => r.clone(),
+            _ => panic!("Expected single proposal reference in {processed_proposals:?}"),
+        };
+
+        assert!(proposal_info.contains(&committed_info));
+
+        #[cfg(feature = "state_update")]
+        match &*processed_proposals.1.unused_proposals {
+            [r] => {
+                assert_ne!(*r, committed_info);
+                assert!(proposal_info.contains(r));
+            }
+            _ => panic!(
+                "Expected one proposal reference in {:?}",
+                processed_proposals.1.unused_proposals
+            ),
+        }
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn receiving_multiple_group_context_extensions_fails() {
+        let (alice, tree) = new_tree("alice").await;
+
+        let res = CommitReceiver::new(
+            &tree,
+            alice,
+            alice,
+            test_cipher_suite_provider(TEST_CIPHER_SUITE),
+        )
+        .receive([
+            Proposal::GroupContextExtensions(ExtensionList::new()),
+            Proposal::GroupContextExtensions(ExtensionList::new()),
+        ])
+        .await;
+
+        assert_matches!(
+            res,
+            Err(MlsError::MoreThanOneGroupContextExtensionsProposal)
+        );
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn sending_multiple_additional_group_context_extensions_fails() {
+        let (alice, tree) = new_tree("alice").await;
+
+        let res = CommitSender::new(&tree, alice, test_cipher_suite_provider(TEST_CIPHER_SUITE))
+            .with_additional([
+                Proposal::GroupContextExtensions(ExtensionList::new()),
+                Proposal::GroupContextExtensions(ExtensionList::new()),
+            ])
+            .send()
+            .await;
+
+        assert_matches!(
+            res,
+            Err(MlsError::MoreThanOneGroupContextExtensionsProposal)
+        );
+    }
+
+    fn make_extension_list(foo: u8) -> ExtensionList {
+        vec![TestExtension { foo }.into_extension().unwrap()].into()
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn sending_multiple_group_context_extensions_keeps_only_one() {
+        let cipher_suite_provider = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+
+        let (alice, tree) = {
+            let (signing_identity, signature_key) =
+                get_test_signing_identity(TEST_CIPHER_SUITE, b"alice").await;
+
+            let properties = ConfigProperties {
+                capabilities: Capabilities {
+                    extensions: vec![42.into()],
+                    ..Capabilities::default()
+                },
+                extensions: Default::default(),
+            };
+
+            let (leaf, secret) = LeafNode::generate(
+                &cipher_suite_provider,
+                properties,
+                signing_identity,
+                &signature_key,
+                Lifetime::years(1).unwrap(),
+            )
+            .await
+            .unwrap();
+
+            let (pub_tree, priv_tree) =
+                TreeKemPublic::derive(leaf, secret, &BasicIdentityProvider, &Default::default())
+                    .await
+                    .unwrap();
+
+            (priv_tree.self_index, pub_tree)
+        };
+
+        let proposals = [
+            Proposal::GroupContextExtensions(make_extension_list(0)),
+            Proposal::GroupContextExtensions(make_extension_list(1)),
+        ];
+
+        let gce_info = [
+            make_proposal_info(&proposals[0], alice).await,
+            make_proposal_info(&proposals[1], alice).await,
+        ];
+
+        let processed_proposals =
+            CommitSender::new(&tree, alice, test_cipher_suite_provider(TEST_CIPHER_SUITE))
+                .cache(
+                    gce_info[0].proposal_ref().unwrap().clone(),
+                    proposals[0].clone(),
+                    alice,
+                )
+                .cache(
+                    gce_info[1].proposal_ref().unwrap().clone(),
+                    proposals[1].clone(),
+                    alice,
+                )
+                .send()
+                .await
+                .unwrap();
+
+        let committed_gce_info = match processed_proposals
+            .1
+            .applied_proposals
+            .clone()
+            .into_proposals()
+            .collect_vec()
+            .as_slice()
+        {
+            [gce_info] => gce_info.clone(),
+            _ => panic!("committed proposals list does not contain exactly one reference"),
+        };
+
+        assert!(gce_info.contains(&committed_gce_info));
+
+        #[cfg(feature = "state_update")]
+        assert_matches!(
+            &*processed_proposals.1.unused_proposals,
+            [rejected_gce_info] if committed_gce_info != *rejected_gce_info && gce_info.contains(rejected_gce_info)
+        );
+    }
+
+    #[cfg(feature = "by_ref_proposal")]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn make_external_senders_extension() -> ExtensionList {
+        let identity = get_test_signing_identity(TEST_CIPHER_SUITE, b"alice")
+            .await
+            .0;
+
+        vec![ExternalSendersExt::new(vec![identity])
+            .into_extension()
+            .unwrap()]
+        .into()
+    }
+
+    #[cfg(feature = "by_ref_proposal")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn receiving_invalid_external_senders_extension_fails() {
+        let (alice, tree) = new_tree("alice").await;
+
+        let res = CommitReceiver::new(
+            &tree,
+            alice,
+            alice,
+            test_cipher_suite_provider(TEST_CIPHER_SUITE),
+        )
+        .with_identity_provider(FailureIdentityProvider::new())
+        .receive([Proposal::GroupContextExtensions(
+            make_external_senders_extension().await,
+        )])
+        .await;
+
+        assert_matches!(res, Err(MlsError::IdentityProviderError(_)));
+    }
+
+    #[cfg(feature = "by_ref_proposal")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn sending_additional_invalid_external_senders_extension_fails() {
+        let (alice, tree) = new_tree("alice").await;
+
+        let res = CommitSender::new(&tree, alice, test_cipher_suite_provider(TEST_CIPHER_SUITE))
+            .with_identity_provider(FailureIdentityProvider::new())
+            .with_additional([Proposal::GroupContextExtensions(
+                make_external_senders_extension().await,
+            )])
+            .send()
+            .await;
+
+        assert_matches!(res, Err(MlsError::IdentityProviderError(_)));
+    }
+
+    #[cfg(feature = "by_ref_proposal")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn sending_invalid_external_senders_extension_filters_it_out() {
+        let (alice, tree) = new_tree("alice").await;
+
+        let proposal = Proposal::GroupContextExtensions(make_external_senders_extension().await);
+
+        let proposal_info = make_proposal_info(&proposal, alice).await;
+
+        let processed_proposals =
+            CommitSender::new(&tree, alice, test_cipher_suite_provider(TEST_CIPHER_SUITE))
+                .with_identity_provider(FailureIdentityProvider::new())
+                .cache(
+                    proposal_info.proposal_ref().unwrap().clone(),
+                    proposal.clone(),
+                    alice,
+                )
+                .send()
+                .await
+                .unwrap();
+
+        assert_eq!(processed_proposals.0, Vec::new());
+
+        #[cfg(feature = "state_update")]
+        assert_eq!(processed_proposals.1.unused_proposals, vec![proposal_info]);
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn receiving_reinit_with_other_proposals_fails() {
+        let (alice, tree) = new_tree("alice").await;
+
+        let res = CommitReceiver::new(
+            &tree,
+            alice,
+            alice,
+            test_cipher_suite_provider(TEST_CIPHER_SUITE),
+        )
+        .receive([
+            Proposal::ReInit(make_reinit(TEST_PROTOCOL_VERSION)),
+            Proposal::Add(make_add_proposal().await),
+        ])
+        .await;
+
+        assert_matches!(res, Err(MlsError::OtherProposalWithReInit));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn sending_additional_reinit_with_other_proposals_fails() {
+        let (alice, tree) = new_tree("alice").await;
+
+        let res = CommitSender::new(&tree, alice, test_cipher_suite_provider(TEST_CIPHER_SUITE))
+            .with_additional([
+                Proposal::ReInit(make_reinit(TEST_PROTOCOL_VERSION)),
+                Proposal::Add(make_add_proposal().await),
+            ])
+            .send()
+            .await;
+
+        assert_matches!(res, Err(MlsError::OtherProposalWithReInit));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn sending_reinit_with_other_proposals_filters_it_out() {
+        let (alice, tree) = new_tree("alice").await;
+        let reinit = Proposal::ReInit(make_reinit(TEST_PROTOCOL_VERSION));
+        let reinit_info = make_proposal_info(&reinit, alice).await;
+        let add = Proposal::Add(make_add_proposal().await);
+        let add_ref = make_proposal_ref(&add, alice).await;
+
+        let processed_proposals =
+            CommitSender::new(&tree, alice, test_cipher_suite_provider(TEST_CIPHER_SUITE))
+                .cache(
+                    reinit_info.proposal_ref().unwrap().clone(),
+                    reinit.clone(),
+                    alice,
+                )
+                .cache(add_ref.clone(), add, alice)
+                .send()
+                .await
+                .unwrap();
+
+        assert_eq!(processed_proposals.0, vec![add_ref.into()]);
+
+        #[cfg(feature = "state_update")]
+        assert_eq!(processed_proposals.1.unused_proposals, vec![reinit_info]);
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn receiving_multiple_reinits_fails() {
+        let (alice, tree) = new_tree("alice").await;
+
+        let res = CommitReceiver::new(
+            &tree,
+            alice,
+            alice,
+            test_cipher_suite_provider(TEST_CIPHER_SUITE),
+        )
+        .receive([
+            Proposal::ReInit(make_reinit(TEST_PROTOCOL_VERSION)),
+            Proposal::ReInit(make_reinit(TEST_PROTOCOL_VERSION)),
+        ])
+        .await;
+
+        assert_matches!(res, Err(MlsError::OtherProposalWithReInit));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn sending_additional_multiple_reinits_fails() {
+        let (alice, tree) = new_tree("alice").await;
+
+        let res = CommitSender::new(&tree, alice, test_cipher_suite_provider(TEST_CIPHER_SUITE))
+            .with_additional([
+                Proposal::ReInit(make_reinit(TEST_PROTOCOL_VERSION)),
+                Proposal::ReInit(make_reinit(TEST_PROTOCOL_VERSION)),
+            ])
+            .send()
+            .await;
+
+        assert_matches!(res, Err(MlsError::OtherProposalWithReInit));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn sending_multiple_reinits_keeps_only_one() {
+        let (alice, tree) = new_tree("alice").await;
+        let reinit = Proposal::ReInit(make_reinit(TEST_PROTOCOL_VERSION));
+        let reinit_ref = make_proposal_ref(&reinit, alice).await;
+        let other_reinit = Proposal::ReInit(ReInitProposal {
+            group_id: b"other_group".to_vec(),
+            ..make_reinit(TEST_PROTOCOL_VERSION)
+        });
+        let other_reinit_ref = make_proposal_ref(&other_reinit, alice).await;
+
+        let processed_proposals =
+            CommitSender::new(&tree, alice, test_cipher_suite_provider(TEST_CIPHER_SUITE))
+                .cache(reinit_ref.clone(), reinit.clone(), alice)
+                .cache(other_reinit_ref.clone(), other_reinit.clone(), alice)
+                .send()
+                .await
+                .unwrap();
+
+        let processed_ref = match &*processed_proposals.0 {
+            [ProposalOrRef::Reference(r)] => r,
+            p => panic!("Expected single proposal reference but found {p:?}"),
+        };
+
+        assert!(*processed_ref == reinit_ref || *processed_ref == other_reinit_ref);
+
+        #[cfg(feature = "state_update")]
+        {
+            let (rejected_ref, unused_proposal) = match &*processed_proposals.1.unused_proposals {
+                [r] => (r.proposal_ref().unwrap().clone(), r.proposal.clone()),
+                p => panic!("Expected single proposal but found {p:?}"),
+            };
+
+            assert_ne!(rejected_ref, *processed_ref);
+            assert!(rejected_ref == reinit_ref || rejected_ref == other_reinit_ref);
+            assert!(unused_proposal == reinit || unused_proposal == other_reinit);
+        }
+    }
+
+    fn make_external_init() -> ExternalInit {
+        ExternalInit {
+            kem_output: vec![33; test_cipher_suite_provider(TEST_CIPHER_SUITE).kdf_extract_size()],
+        }
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn receiving_external_init_from_member_fails() {
+        let (alice, tree) = new_tree("alice").await;
+
+        let res = CommitReceiver::new(
+            &tree,
+            alice,
+            alice,
+            test_cipher_suite_provider(TEST_CIPHER_SUITE),
+        )
+        .receive([Proposal::ExternalInit(make_external_init())])
+        .await;
+
+        assert_matches!(res, Err(MlsError::InvalidProposalTypeForSender));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn sending_additional_external_init_from_member_fails() {
+        let (alice, tree) = new_tree("alice").await;
+
+        let res = CommitSender::new(&tree, alice, test_cipher_suite_provider(TEST_CIPHER_SUITE))
+            .with_additional([Proposal::ExternalInit(make_external_init())])
+            .send()
+            .await;
+
+        assert_matches!(res, Err(MlsError::InvalidProposalTypeForSender));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn sending_external_init_from_member_filters_it_out() {
+        let (alice, tree) = new_tree("alice").await;
+        let external_init = Proposal::ExternalInit(make_external_init());
+        let external_init_info = make_proposal_info(&external_init, alice).await;
+
+        let processed_proposals =
+            CommitSender::new(&tree, alice, test_cipher_suite_provider(TEST_CIPHER_SUITE))
+                .cache(
+                    external_init_info.proposal_ref().unwrap().clone(),
+                    external_init.clone(),
+                    alice,
+                )
+                .send()
+                .await
+                .unwrap();
+
+        assert_eq!(processed_proposals.0, Vec::new());
+
+        #[cfg(feature = "state_update")]
+        assert_eq!(
+            processed_proposals.1.unused_proposals,
+            vec![external_init_info]
+        );
+    }
+
+    fn required_capabilities_proposal(extension: u16) -> Proposal {
+        let required_capabilities = RequiredCapabilitiesExt {
+            extensions: vec![extension.into()],
+            ..Default::default()
+        };
+
+        let ext = vec![required_capabilities.into_extension().unwrap()];
+
+        Proposal::GroupContextExtensions(ext.into())
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn receiving_required_capabilities_not_supported_by_member_fails() {
+        let (alice, tree) = new_tree("alice").await;
+
+        let res = CommitReceiver::new(
+            &tree,
+            alice,
+            alice,
+            test_cipher_suite_provider(TEST_CIPHER_SUITE),
+        )
+        .receive([required_capabilities_proposal(33)])
+        .await;
+
+        assert_matches!(
+            res,
+            Err(MlsError::RequiredExtensionNotFound(v)) if v == 33.into()
+        );
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn sending_required_capabilities_not_supported_by_member_fails() {
+        let (alice, tree) = new_tree("alice").await;
+
+        let res = CommitSender::new(&tree, alice, test_cipher_suite_provider(TEST_CIPHER_SUITE))
+            .with_additional([required_capabilities_proposal(33)])
+            .send()
+            .await;
+
+        assert_matches!(
+            res,
+            Err(MlsError::RequiredExtensionNotFound(v)) if v == 33.into()
+        );
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn sending_additional_required_capabilities_not_supported_by_member_filters_it_out() {
+        let (alice, tree) = new_tree("alice").await;
+
+        let proposal = required_capabilities_proposal(33);
+        let proposal_info = make_proposal_info(&proposal, alice).await;
+
+        let processed_proposals =
+            CommitSender::new(&tree, alice, test_cipher_suite_provider(TEST_CIPHER_SUITE))
+                .cache(
+                    proposal_info.proposal_ref().unwrap().clone(),
+                    proposal.clone(),
+                    alice,
+                )
+                .send()
+                .await
+                .unwrap();
+
+        assert_eq!(processed_proposals.0, Vec::new());
+
+        #[cfg(feature = "state_update")]
+        assert_eq!(processed_proposals.1.unused_proposals, vec![proposal_info]);
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn committing_update_from_pk1_to_pk2_and_update_from_pk2_to_pk3_works() {
+        let (alice_leaf, alice_secret, alice_signer) =
+            get_basic_test_node_sig_key(TEST_CIPHER_SUITE, "alice").await;
+
+        let (mut tree, priv_tree) = TreeKemPublic::derive(
+            alice_leaf.clone(),
+            alice_secret,
+            &BasicIdentityProvider,
+            &Default::default(),
+        )
+        .await
+        .unwrap();
+
+        let alice = priv_tree.self_index;
+
+        let bob = add_member(&mut tree, "bob").await;
+        let carol = add_member(&mut tree, "carol").await;
+
+        let bob_current_leaf = tree.get_leaf_node(bob).unwrap();
+
+        let mut alice_new_leaf = LeafNode {
+            public_key: bob_current_leaf.public_key.clone(),
+            leaf_node_source: LeafNodeSource::Update,
+            ..alice_leaf
+        };
+
+        alice_new_leaf
+            .sign(
+                &test_cipher_suite_provider(TEST_CIPHER_SUITE),
+                &alice_signer,
+                &(TEST_GROUP, 0).into(),
+            )
+            .await
+            .unwrap();
+
+        let bob_new_leaf = update_leaf_node("bob", 1).await;
+
+        let pk1_to_pk2 = Proposal::Update(UpdateProposal {
+            leaf_node: alice_new_leaf.clone(),
+        });
+
+        let pk1_to_pk2_ref = make_proposal_ref(&pk1_to_pk2, alice).await;
+
+        let pk2_to_pk3 = Proposal::Update(UpdateProposal {
+            leaf_node: bob_new_leaf.clone(),
+        });
+
+        let pk2_to_pk3_ref = make_proposal_ref(&pk2_to_pk3, bob).await;
+
+        let effects = CommitReceiver::new(
+            &tree,
+            carol,
+            carol,
+            test_cipher_suite_provider(TEST_CIPHER_SUITE),
+        )
+        .cache(pk1_to_pk2_ref.clone(), pk1_to_pk2, alice)
+        .cache(pk2_to_pk3_ref.clone(), pk2_to_pk3, bob)
+        .receive([pk1_to_pk2_ref, pk2_to_pk3_ref])
+        .await
+        .unwrap();
+
+        assert_eq!(effects.applied_proposals.update_senders, vec![alice, bob]);
+
+        assert_eq!(
+            effects
+                .applied_proposals
+                .updates
+                .into_iter()
+                .map(|p| p.proposal.leaf_node)
+                .collect_vec(),
+            vec![alice_new_leaf, bob_new_leaf]
+        );
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn committing_update_from_pk1_to_pk2_and_removal_of_pk2_works() {
+        let cipher_suite_provider = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+
+        let (alice_leaf, alice_secret, alice_signer) =
+            get_basic_test_node_sig_key(TEST_CIPHER_SUITE, "alice").await;
+
+        let (mut tree, priv_tree) = TreeKemPublic::derive(
+            alice_leaf.clone(),
+            alice_secret,
+            &BasicIdentityProvider,
+            &Default::default(),
+        )
+        .await
+        .unwrap();
+
+        let alice = priv_tree.self_index;
+
+        let bob = add_member(&mut tree, "bob").await;
+        let carol = add_member(&mut tree, "carol").await;
+
+        let bob_current_leaf = tree.get_leaf_node(bob).unwrap();
+
+        let mut alice_new_leaf = LeafNode {
+            public_key: bob_current_leaf.public_key.clone(),
+            leaf_node_source: LeafNodeSource::Update,
+            ..alice_leaf
+        };
+
+        alice_new_leaf
+            .sign(
+                &cipher_suite_provider,
+                &alice_signer,
+                &(TEST_GROUP, 0).into(),
+            )
+            .await
+            .unwrap();
+
+        let pk1_to_pk2 = Proposal::Update(UpdateProposal {
+            leaf_node: alice_new_leaf.clone(),
+        });
+
+        let pk1_to_pk2_ref = make_proposal_ref(&pk1_to_pk2, alice).await;
+
+        let remove_pk2 = Proposal::Remove(RemoveProposal { to_remove: bob });
+
+        let remove_pk2_ref = make_proposal_ref(&remove_pk2, bob).await;
+
+        let effects = CommitReceiver::new(
+            &tree,
+            carol,
+            carol,
+            test_cipher_suite_provider(TEST_CIPHER_SUITE),
+        )
+        .cache(pk1_to_pk2_ref.clone(), pk1_to_pk2, alice)
+        .cache(remove_pk2_ref.clone(), remove_pk2, bob)
+        .receive([pk1_to_pk2_ref, remove_pk2_ref])
+        .await
+        .unwrap();
+
+        assert_eq!(effects.applied_proposals.update_senders, vec![alice]);
+
+        assert_eq!(
+            effects
+                .applied_proposals
+                .updates
+                .into_iter()
+                .map(|p| p.proposal.leaf_node)
+                .collect_vec(),
+            vec![alice_new_leaf]
+        );
+
+        assert_eq!(
+            effects
+                .applied_proposals
+                .removals
+                .into_iter()
+                .map(|p| p.proposal.to_remove)
+                .collect_vec(),
+            vec![bob]
+        );
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn unsupported_credential_key_package(name: &str) -> KeyPackage {
+        let (mut signing_identity, secret_key) =
+            get_test_signing_identity(TEST_CIPHER_SUITE, name.as_bytes()).await;
+
+        signing_identity.credential = Credential::Custom(CustomCredential::new(
+            CredentialType::new(BasicWithCustomProvider::CUSTOM_CREDENTIAL_TYPE),
+            random_bytes(32),
+        ));
+
+        let generator = KeyPackageGenerator {
+            protocol_version: TEST_PROTOCOL_VERSION,
+            cipher_suite_provider: &test_cipher_suite_provider(TEST_CIPHER_SUITE),
+            signing_identity: &signing_identity,
+            signing_key: &secret_key,
+            identity_provider: &BasicWithCustomProvider::new(BasicIdentityProvider::new()),
+        };
+
+        generator
+            .generate(
+                Lifetime::years(1).unwrap(),
+                Capabilities {
+                    credentials: vec![42.into()],
+                    ..Default::default()
+                },
+                Default::default(),
+                Default::default(),
+            )
+            .await
+            .unwrap()
+            .key_package
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn receiving_add_with_leaf_not_supporting_credential_type_of_other_leaf_fails() {
+        let (alice, tree) = new_tree("alice").await;
+
+        let res = CommitReceiver::new(
+            &tree,
+            alice,
+            alice,
+            test_cipher_suite_provider(TEST_CIPHER_SUITE),
+        )
+        .receive([Proposal::Add(Box::new(AddProposal {
+            key_package: unsupported_credential_key_package("bob").await,
+        }))])
+        .await;
+
+        assert_matches!(res, Err(MlsError::InUseCredentialTypeUnsupportedByNewLeaf));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn sending_additional_add_with_leaf_not_supporting_credential_type_of_other_leaf_fails() {
+        let (alice, tree) = new_tree("alice").await;
+
+        let res = CommitSender::new(&tree, alice, test_cipher_suite_provider(TEST_CIPHER_SUITE))
+            .with_additional([Proposal::Add(Box::new(AddProposal {
+                key_package: unsupported_credential_key_package("bob").await,
+            }))])
+            .send()
+            .await;
+
+        assert_matches!(res, Err(MlsError::InUseCredentialTypeUnsupportedByNewLeaf));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn sending_add_with_leaf_not_supporting_credential_type_of_other_leaf_filters_it_out() {
+        let (alice, tree) = new_tree("alice").await;
+
+        let add = Proposal::Add(Box::new(AddProposal {
+            key_package: unsupported_credential_key_package("bob").await,
+        }));
+
+        let add_info = make_proposal_info(&add, alice).await;
+
+        let processed_proposals =
+            CommitSender::new(&tree, alice, test_cipher_suite_provider(TEST_CIPHER_SUITE))
+                .cache(add_info.proposal_ref().unwrap().clone(), add.clone(), alice)
+                .send()
+                .await
+                .unwrap();
+
+        assert_eq!(processed_proposals.0, Vec::new());
+
+        #[cfg(feature = "state_update")]
+        assert_eq!(processed_proposals.1.unused_proposals, vec![add_info]);
+    }
+
+    #[cfg(feature = "custom_proposal")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn sending_custom_proposal_with_member_not_supporting_proposal_type_fails() {
+        let (alice, tree) = new_tree("alice").await;
+
+        let custom_proposal = Proposal::Custom(CustomProposal::new(ProposalType::new(42), vec![]));
+
+        let res = CommitSender::new(&tree, alice, test_cipher_suite_provider(TEST_CIPHER_SUITE))
+            .with_additional([custom_proposal.clone()])
+            .send()
+            .await;
+
+        assert_matches!(
+            res,
+            Err(
+                MlsError::UnsupportedCustomProposal(c)
+            ) if c == custom_proposal.proposal_type()
+        );
+    }
+
+    #[cfg(feature = "custom_proposal")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn sending_custom_proposal_with_member_not_supporting_filters_it_out() {
+        let (alice, tree) = new_tree("alice").await;
+
+        let custom_proposal = Proposal::Custom(CustomProposal::new(ProposalType::new(42), vec![]));
+
+        let custom_info = make_proposal_info(&custom_proposal, alice).await;
+
+        let processed_proposals =
+            CommitSender::new(&tree, alice, test_cipher_suite_provider(TEST_CIPHER_SUITE))
+                .cache(
+                    custom_info.proposal_ref().unwrap().clone(),
+                    custom_proposal.clone(),
+                    alice,
+                )
+                .send()
+                .await
+                .unwrap();
+
+        assert_eq!(processed_proposals.0, Vec::new());
+
+        #[cfg(feature = "state_update")]
+        assert_eq!(processed_proposals.1.unused_proposals, vec![custom_info]);
+    }
+
+    #[cfg(feature = "custom_proposal")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn receiving_custom_proposal_with_member_not_supporting_fails() {
+        let (alice, tree) = new_tree("alice").await;
+
+        let custom_proposal = Proposal::Custom(CustomProposal::new(ProposalType::new(42), vec![]));
+
+        let res = CommitReceiver::new(
+            &tree,
+            alice,
+            alice,
+            test_cipher_suite_provider(TEST_CIPHER_SUITE),
+        )
+        .receive([custom_proposal.clone()])
+        .await;
+
+        assert_matches!(
+            res,
+            Err(MlsError::UnsupportedCustomProposal(c)) if c == custom_proposal.proposal_type()
+        );
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn receiving_group_extension_unsupported_by_leaf_fails() {
+        let (alice, tree) = new_tree("alice").await;
+
+        let res = CommitReceiver::new(
+            &tree,
+            alice,
+            alice,
+            test_cipher_suite_provider(TEST_CIPHER_SUITE),
+        )
+        .receive([Proposal::GroupContextExtensions(make_extension_list(0))])
+        .await;
+
+        assert_matches!(
+            res,
+            Err(
+                MlsError::UnsupportedGroupExtension(v)
+            ) if v == 42.into()
+        );
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn sending_additional_group_extension_unsupported_by_leaf_fails() {
+        let (alice, tree) = new_tree("alice").await;
+
+        let res = CommitSender::new(&tree, alice, test_cipher_suite_provider(TEST_CIPHER_SUITE))
+            .with_additional([Proposal::GroupContextExtensions(make_extension_list(0))])
+            .send()
+            .await;
+
+        assert_matches!(
+            res,
+            Err(
+                MlsError::UnsupportedGroupExtension(v)
+            ) if v == 42.into()
+        );
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn sending_group_extension_unsupported_by_leaf_filters_it_out() {
+        let (alice, tree) = new_tree("alice").await;
+
+        let proposal = Proposal::GroupContextExtensions(make_extension_list(0));
+        let proposal_info = make_proposal_info(&proposal, alice).await;
+
+        let processed_proposals =
+            CommitSender::new(&tree, alice, test_cipher_suite_provider(TEST_CIPHER_SUITE))
+                .cache(
+                    proposal_info.proposal_ref().unwrap().clone(),
+                    proposal.clone(),
+                    alice,
+                )
+                .send()
+                .await
+                .unwrap();
+
+        assert_eq!(processed_proposals.0, Vec::new());
+
+        #[cfg(feature = "state_update")]
+        assert_eq!(processed_proposals.1.unused_proposals, vec![proposal_info]);
+    }
+
+    #[cfg(feature = "psk")]
+    #[derive(Debug)]
+    struct AlwaysNotFoundPskStorage;
+
+    #[cfg(feature = "psk")]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    #[cfg_attr(mls_build_async, maybe_async::must_be_async)]
+    impl PreSharedKeyStorage for AlwaysNotFoundPskStorage {
+        type Error = Infallible;
+
+        async fn get(&self, _: &ExternalPskId) -> Result<Option<PreSharedKey>, Self::Error> {
+            Ok(None)
+        }
+    }
+
+    #[cfg(feature = "psk")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn receiving_external_psk_with_unknown_id_fails() {
+        let (alice, tree) = new_tree("alice").await;
+
+        let res = CommitReceiver::new(
+            &tree,
+            alice,
+            alice,
+            test_cipher_suite_provider(TEST_CIPHER_SUITE),
+        )
+        .with_psk_storage(AlwaysNotFoundPskStorage)
+        .receive([Proposal::Psk(new_external_psk(b"abc"))])
+        .await;
+
+        assert_matches!(res, Err(MlsError::MissingRequiredPsk));
+    }
+
+    #[cfg(feature = "psk")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn sending_additional_external_psk_with_unknown_id_fails() {
+        let (alice, tree) = new_tree("alice").await;
+
+        let res = CommitSender::new(&tree, alice, test_cipher_suite_provider(TEST_CIPHER_SUITE))
+            .with_psk_storage(AlwaysNotFoundPskStorage)
+            .with_additional([Proposal::Psk(new_external_psk(b"abc"))])
+            .send()
+            .await;
+
+        assert_matches!(res, Err(MlsError::MissingRequiredPsk));
+    }
+
+    #[cfg(feature = "psk")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn sending_external_psk_with_unknown_id_filters_it_out() {
+        let (alice, tree) = new_tree("alice").await;
+        let proposal = Proposal::Psk(new_external_psk(b"abc"));
+        let proposal_info = make_proposal_info(&proposal, alice).await;
+
+        let processed_proposals =
+            CommitSender::new(&tree, alice, test_cipher_suite_provider(TEST_CIPHER_SUITE))
+                .with_psk_storage(AlwaysNotFoundPskStorage)
+                .cache(
+                    proposal_info.proposal_ref().unwrap().clone(),
+                    proposal.clone(),
+                    alice,
+                )
+                .send()
+                .await
+                .unwrap();
+
+        assert_eq!(processed_proposals.0, Vec::new());
+
+        #[cfg(feature = "state_update")]
+        assert_eq!(processed_proposals.1.unused_proposals, vec![proposal_info]);
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn user_defined_filter_can_remove_proposals() {
+        struct RemoveGroupContextExtensions;
+
+        #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+        #[cfg_attr(mls_build_async, maybe_async::must_be_async)]
+        impl MlsRules for RemoveGroupContextExtensions {
+            type Error = Infallible;
+
+            async fn filter_proposals(
+                &self,
+                _: CommitDirection,
+                _: CommitSource,
+                _: &Roster,
+                _: &ExtensionList,
+                mut proposals: ProposalBundle,
+            ) -> Result<ProposalBundle, Self::Error> {
+                proposals.group_context_extensions.clear();
+                Ok(proposals)
+            }
+
+            #[cfg_attr(coverage_nightly, coverage(off))]
+            fn commit_options(
+                &self,
+                _: &Roster,
+                _: &ExtensionList,
+                _: &ProposalBundle,
+            ) -> Result<CommitOptions, Self::Error> {
+                Ok(Default::default())
+            }
+
+            #[cfg_attr(coverage_nightly, coverage(off))]
+            fn encryption_options(
+                &self,
+                _: &Roster,
+                _: &ExtensionList,
+            ) -> Result<EncryptionOptions, Self::Error> {
+                Ok(Default::default())
+            }
+        }
+
+        let (alice, tree) = new_tree("alice").await;
+
+        let (committed, _) =
+            CommitSender::new(&tree, alice, test_cipher_suite_provider(TEST_CIPHER_SUITE))
+                .with_additional([Proposal::GroupContextExtensions(Default::default())])
+                .with_user_rules(RemoveGroupContextExtensions)
+                .send()
+                .await
+                .unwrap();
+
+        assert_eq!(committed, Vec::new());
+    }
+
+    struct FailureMlsRules;
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    #[cfg_attr(mls_build_async, maybe_async::must_be_async)]
+    impl MlsRules for FailureMlsRules {
+        type Error = MlsError;
+
+        async fn filter_proposals(
+            &self,
+            _: CommitDirection,
+            _: CommitSource,
+            _: &Roster,
+            _: &ExtensionList,
+            _: ProposalBundle,
+        ) -> Result<ProposalBundle, Self::Error> {
+            Err(MlsError::InvalidSignature)
+        }
+
+        #[cfg_attr(coverage_nightly, coverage(off))]
+        fn commit_options(
+            &self,
+            _: &Roster,
+            _: &ExtensionList,
+            _: &ProposalBundle,
+        ) -> Result<CommitOptions, Self::Error> {
+            Ok(Default::default())
+        }
+
+        #[cfg_attr(coverage_nightly, coverage(off))]
+        fn encryption_options(
+            &self,
+            _: &Roster,
+            _: &ExtensionList,
+        ) -> Result<EncryptionOptions, Self::Error> {
+            Ok(Default::default())
+        }
+    }
+
+    struct InjectMlsRules {
+        to_inject: Proposal,
+        source: ProposalSource,
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    #[cfg_attr(mls_build_async, maybe_async::must_be_async)]
+    impl MlsRules for InjectMlsRules {
+        type Error = MlsError;
+
+        async fn filter_proposals(
+            &self,
+            _: CommitDirection,
+            _: CommitSource,
+            _: &Roster,
+            _: &ExtensionList,
+            mut proposals: ProposalBundle,
+        ) -> Result<ProposalBundle, Self::Error> {
+            proposals.add(
+                self.to_inject.clone(),
+                Sender::Member(0),
+                self.source.clone(),
+            );
+            Ok(proposals)
+        }
+
+        #[cfg_attr(coverage_nightly, coverage(off))]
+        fn commit_options(
+            &self,
+            _: &Roster,
+            _: &ExtensionList,
+            _: &ProposalBundle,
+        ) -> Result<CommitOptions, Self::Error> {
+            Ok(Default::default())
+        }
+
+        #[cfg_attr(coverage_nightly, coverage(off))]
+        fn encryption_options(
+            &self,
+            _: &Roster,
+            _: &ExtensionList,
+        ) -> Result<EncryptionOptions, Self::Error> {
+            Ok(Default::default())
+        }
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn user_defined_filter_can_inject_proposals() {
+        let (alice, tree) = new_tree("alice").await;
+
+        let test_proposal = Proposal::GroupContextExtensions(Default::default());
+
+        let (committed, _) =
+            CommitSender::new(&tree, alice, test_cipher_suite_provider(TEST_CIPHER_SUITE))
+                .with_user_rules(InjectMlsRules {
+                    to_inject: test_proposal.clone(),
+                    source: ProposalSource::ByValue,
+                })
+                .send()
+                .await
+                .unwrap();
+
+        assert_eq!(
+            committed,
+            vec![ProposalOrRef::Proposal(test_proposal.into())]
+        );
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn user_defined_filter_can_inject_local_only_proposals() {
+        let (alice, tree) = new_tree("alice").await;
+
+        let test_proposal = Proposal::GroupContextExtensions(Default::default());
+
+        let (committed, _) =
+            CommitSender::new(&tree, alice, test_cipher_suite_provider(TEST_CIPHER_SUITE))
+                .with_user_rules(InjectMlsRules {
+                    to_inject: test_proposal.clone(),
+                    source: ProposalSource::Local,
+                })
+                .send()
+                .await
+                .unwrap();
+
+        assert_eq!(committed, vec![]);
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn user_defined_filter_cant_break_base_rules() {
+        let (alice, tree) = new_tree("alice").await;
+
+        let test_proposal = Proposal::Update(UpdateProposal {
+            leaf_node: get_basic_test_node(TEST_CIPHER_SUITE, "leaf").await,
+        });
+
+        let res = CommitSender::new(&tree, alice, test_cipher_suite_provider(TEST_CIPHER_SUITE))
+            .with_user_rules(InjectMlsRules {
+                to_inject: test_proposal.clone(),
+                source: ProposalSource::ByValue,
+            })
+            .send()
+            .await;
+
+        assert_matches!(res, Err(MlsError::InvalidProposalTypeForSender { .. }))
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn user_defined_filter_can_refuse_to_send_commit() {
+        let (alice, tree) = new_tree("alice").await;
+
+        let res = CommitSender::new(&tree, alice, test_cipher_suite_provider(TEST_CIPHER_SUITE))
+            .with_additional([Proposal::GroupContextExtensions(Default::default())])
+            .with_user_rules(FailureMlsRules)
+            .send()
+            .await;
+
+        assert_matches!(res, Err(MlsError::MlsRulesError(_)));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn user_defined_filter_can_reject_incoming_commit() {
+        let (alice, tree) = new_tree("alice").await;
+
+        let res = CommitReceiver::new(
+            &tree,
+            alice,
+            alice,
+            test_cipher_suite_provider(TEST_CIPHER_SUITE),
+        )
+        .with_user_rules(FailureMlsRules)
+        .receive([Proposal::GroupContextExtensions(Default::default())])
+        .await;
+
+        assert_matches!(res, Err(MlsError::MlsRulesError(_)));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn proposers_are_verified() {
+        let (alice, mut tree) = new_tree("alice").await;
+        let bob = add_member(&mut tree, "bob").await;
+
+        #[cfg(feature = "by_ref_proposal")]
+        let identity = get_test_signing_identity(TEST_CIPHER_SUITE, b"carol")
+            .await
+            .0;
+
+        #[cfg(feature = "by_ref_proposal")]
+        let external_senders = ExternalSendersExt::new(vec![identity]);
+
+        let proposals: &[Proposal] = &[
+            Proposal::Add(make_add_proposal().await),
+            Proposal::Update(make_update_proposal("alice").await),
+            Proposal::Remove(RemoveProposal { to_remove: bob }),
+            #[cfg(feature = "psk")]
+            Proposal::Psk(make_external_psk(
+                b"ted",
+                PskNonce::random(&test_cipher_suite_provider(TEST_CIPHER_SUITE)).unwrap(),
+            )),
+            Proposal::ReInit(make_reinit(TEST_PROTOCOL_VERSION)),
+            Proposal::ExternalInit(make_external_init()),
+            Proposal::GroupContextExtensions(Default::default()),
+        ];
+
+        let proposers = [
+            Sender::Member(*alice),
+            #[cfg(feature = "by_ref_proposal")]
+            Sender::External(0),
+            Sender::NewMemberCommit,
+            Sender::NewMemberProposal,
+        ];
+
+        for ((proposer, proposal), by_ref) in proposers
+            .into_iter()
+            .cartesian_product(proposals)
+            .cartesian_product([true])
+        {
+            let committer = Sender::Member(*alice);
+
+            let receiver = CommitReceiver::new(
+                &tree,
+                committer,
+                alice,
+                test_cipher_suite_provider(TEST_CIPHER_SUITE),
+            );
+
+            #[cfg(feature = "by_ref_proposal")]
+            let extensions: ExtensionList =
+                vec![external_senders.clone().into_extension().unwrap()].into();
+
+            #[cfg(feature = "by_ref_proposal")]
+            let receiver = receiver.with_extensions(extensions);
+
+            let (receiver, proposals, proposer) = if by_ref {
+                let proposal_ref = make_proposal_ref(proposal, proposer).await;
+                let receiver = receiver.cache(proposal_ref.clone(), proposal.clone(), proposer);
+                (receiver, vec![ProposalOrRef::from(proposal_ref)], proposer)
+            } else {
+                (receiver, vec![proposal.clone().into()], committer)
+            };
+
+            let res = receiver.receive(proposals).await;
+
+            if proposer_can_propose(proposer, proposal.proposal_type(), by_ref).is_err() {
+                assert_matches!(res, Err(MlsError::InvalidProposalTypeForSender));
+            } else {
+                let is_self_update = proposal.proposal_type() == ProposalType::UPDATE
+                    && by_ref
+                    && matches!(proposer, Sender::Member(_));
+
+                if !is_self_update {
+                    res.unwrap();
+                }
+            }
+        }
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn make_update_proposal(name: &str) -> UpdateProposal {
+        UpdateProposal {
+            leaf_node: update_leaf_node(name, 1).await,
+        }
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn make_update_proposal_custom(name: &str, leaf_index: u32) -> UpdateProposal {
+        UpdateProposal {
+            leaf_node: update_leaf_node(name, leaf_index).await,
+        }
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn when_receiving_commit_unused_proposals_are_proposals_in_cache_but_not_in_commit() {
+        let (alice, tree) = new_tree("alice").await;
+
+        let proposal = Proposal::GroupContextExtensions(Default::default());
+        let proposal_ref = make_proposal_ref(&proposal, alice).await;
+
+        let state = CommitReceiver::new(
+            &tree,
+            alice,
+            alice,
+            test_cipher_suite_provider(TEST_CIPHER_SUITE),
+        )
+        .cache(proposal_ref.clone(), proposal, alice)
+        .receive([Proposal::Add(Box::new(AddProposal {
+            key_package: test_key_package(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, "bob").await,
+        }))])
+        .await
+        .unwrap();
+
+        let [p] = &state.unused_proposals[..] else {
+            panic!(
+                "Expected single unused proposal but got {:?}",
+                state.unused_proposals
+            );
+        };
+
+        assert_eq!(p.proposal_ref(), Some(&proposal_ref));
+    }
+}
diff --git a/src/group/proposal_filter.rs b/src/group/proposal_filter.rs
new file mode 100644
index 0000000..5ef6b20
--- /dev/null
+++ b/src/group/proposal_filter.rs
@@ -0,0 +1,23 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// Copyright by contributors to this project.
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+
+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};
+
+#[cfg(feature = "by_ref_proposal")]
+pub(crate) use filtering::FilterStrategy;
+
+pub(crate) use filtering_common::ProposalApplier;
+
+#[cfg(all(feature = "by_ref_proposal", test))]
+pub(crate) use filtering::proposer_can_propose;
diff --git a/src/group/proposal_filter/bundle.rs b/src/group/proposal_filter/bundle.rs
new file mode 100644
index 0000000..f18a75b
--- /dev/null
+++ b/src/group/proposal_filter/bundle.rs
@@ -0,0 +1,633 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// Copyright by contributors to this project.
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+
+use alloc::boxed::Box;
+use alloc::vec::Vec;
+
+#[cfg(feature = "custom_proposal")]
+use itertools::Itertools;
+
+use crate::{
+    group::{
+        AddProposal, BorrowedProposal, Proposal, ProposalOrRef, ProposalType, ReInitProposal,
+        RemoveProposal, Sender,
+    },
+    ExtensionList,
+};
+
+#[cfg(feature = "by_ref_proposal")]
+use crate::group::{proposal_cache::CachedProposal, LeafIndex, ProposalRef, UpdateProposal};
+
+#[cfg(feature = "psk")]
+use crate::group::PreSharedKeyProposal;
+
+#[cfg(feature = "custom_proposal")]
+use crate::group::proposal::CustomProposal;
+
+use crate::group::ExternalInit;
+
+use core::iter::empty;
+
+#[derive(Clone, Debug, Default)]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+/// A collection of proposals.
+pub struct ProposalBundle {
+    pub(crate) additions: Vec<ProposalInfo<AddProposal>>,
+    #[cfg(feature = "by_ref_proposal")]
+    pub(crate) updates: Vec<ProposalInfo<UpdateProposal>>,
+    #[cfg(feature = "by_ref_proposal")]
+    pub(crate) update_senders: Vec<LeafIndex>,
+    pub(crate) removals: Vec<ProposalInfo<RemoveProposal>>,
+    #[cfg(feature = "psk")]
+    pub(crate) psks: Vec<ProposalInfo<PreSharedKeyProposal>>,
+    pub(crate) reinitializations: Vec<ProposalInfo<ReInitProposal>>,
+    pub(crate) external_initializations: Vec<ProposalInfo<ExternalInit>>,
+    pub(crate) group_context_extensions: Vec<ProposalInfo<ExtensionList>>,
+    #[cfg(feature = "custom_proposal")]
+    pub(crate) custom_proposals: Vec<ProposalInfo<CustomProposal>>,
+}
+
+impl ProposalBundle {
+    pub fn add(&mut self, proposal: Proposal, sender: Sender, source: ProposalSource) {
+        match proposal {
+            Proposal::Add(proposal) => self.additions.push(ProposalInfo {
+                proposal: *proposal,
+                sender,
+                source,
+            }),
+            #[cfg(feature = "by_ref_proposal")]
+            Proposal::Update(proposal) => self.updates.push(ProposalInfo {
+                proposal,
+                sender,
+                source,
+            }),
+            Proposal::Remove(proposal) => self.removals.push(ProposalInfo {
+                proposal,
+                sender,
+                source,
+            }),
+            #[cfg(feature = "psk")]
+            Proposal::Psk(proposal) => self.psks.push(ProposalInfo {
+                proposal,
+                sender,
+                source,
+            }),
+            Proposal::ReInit(proposal) => self.reinitializations.push(ProposalInfo {
+                proposal,
+                sender,
+                source,
+            }),
+            Proposal::ExternalInit(proposal) => self.external_initializations.push(ProposalInfo {
+                proposal,
+                sender,
+                source,
+            }),
+            Proposal::GroupContextExtensions(proposal) => {
+                self.group_context_extensions.push(ProposalInfo {
+                    proposal,
+                    sender,
+                    source,
+                })
+            }
+            #[cfg(feature = "custom_proposal")]
+            Proposal::Custom(proposal) => self.custom_proposals.push(ProposalInfo {
+                proposal,
+                sender,
+                source,
+            }),
+        }
+    }
+
+    /// Remove the proposal of type `T` at `index`
+    ///
+    /// Type `T` can be any of the standard MLS proposal types defined in the
+    /// [`proposal`](crate::group::proposal) module.
+    ///
+    /// `index` is consistent with the index returned by any of the proposal
+    /// type specific functions in this module.
+    pub fn remove<T: Proposable>(&mut self, index: usize) {
+        T::remove(self, index);
+    }
+
+    /// Iterate over proposals, filtered by type.
+    ///
+    /// Type `T` can be any of the standard MLS proposal types defined in the
+    /// [`proposal`](crate::group::proposal) module.
+    pub fn by_type<'a, T: Proposable + 'a>(&'a self) -> impl Iterator<Item = &'a ProposalInfo<T>> {
+        T::filter(self).iter()
+    }
+
+    /// Retain proposals, filtered by type.
+    ///
+    /// Type `T` can be any of the standard MLS proposal types defined in the
+    /// [`proposal`](crate::group::proposal) module.
+    pub fn retain_by_type<T, F, E>(&mut self, mut f: F) -> Result<(), E>
+    where
+        T: Proposable,
+        F: FnMut(&ProposalInfo<T>) -> Result<bool, E>,
+    {
+        let mut res = Ok(());
+
+        T::retain(self, |p| match f(p) {
+            Ok(keep) => keep,
+            Err(e) => {
+                if res.is_ok() {
+                    res = Err(e);
+                }
+                false
+            }
+        });
+
+        res
+    }
+
+    /// Retain custom proposals in the bundle.
+    #[cfg(feature = "custom_proposal")]
+    pub fn retain_custom<F, E>(&mut self, mut f: F) -> Result<(), E>
+    where
+        F: FnMut(&ProposalInfo<CustomProposal>) -> Result<bool, E>,
+    {
+        let mut res = Ok(());
+
+        self.custom_proposals.retain(|p| match f(p) {
+            Ok(keep) => keep,
+            Err(e) => {
+                if res.is_ok() {
+                    res = Err(e);
+                }
+                false
+            }
+        });
+
+        res
+    }
+
+    /// Retain MLS standard proposals in the bundle.
+    pub fn retain<F, E>(&mut self, mut f: F) -> Result<(), E>
+    where
+        F: FnMut(&ProposalInfo<BorrowedProposal<'_>>) -> Result<bool, E>,
+    {
+        self.retain_by_type::<AddProposal, _, _>(|proposal| {
+            f(&proposal.as_ref().map(BorrowedProposal::from))
+        })?;
+
+        #[cfg(feature = "by_ref_proposal")]
+        self.retain_by_type::<UpdateProposal, _, _>(|proposal| {
+            f(&proposal.as_ref().map(BorrowedProposal::from))
+        })?;
+
+        self.retain_by_type::<RemoveProposal, _, _>(|proposal| {
+            f(&proposal.as_ref().map(BorrowedProposal::from))
+        })?;
+
+        #[cfg(feature = "psk")]
+        self.retain_by_type::<PreSharedKeyProposal, _, _>(|proposal| {
+            f(&proposal.as_ref().map(BorrowedProposal::from))
+        })?;
+
+        self.retain_by_type::<ReInitProposal, _, _>(|proposal| {
+            f(&proposal.as_ref().map(BorrowedProposal::from))
+        })?;
+
+        self.retain_by_type::<ExternalInit, _, _>(|proposal| {
+            f(&proposal.as_ref().map(BorrowedProposal::from))
+        })?;
+
+        self.retain_by_type::<ExtensionList, _, _>(|proposal| {
+            f(&proposal.as_ref().map(BorrowedProposal::from))
+        })?;
+
+        Ok(())
+    }
+
+    /// The number of proposals in the bundle
+    pub fn length(&self) -> usize {
+        let len = 0;
+
+        #[cfg(feature = "psk")]
+        let len = len + self.psks.len();
+
+        let len = len + self.external_initializations.len();
+
+        #[cfg(feature = "custom_proposal")]
+        let len = len + self.custom_proposals.len();
+
+        #[cfg(feature = "by_ref_proposal")]
+        let len = len + self.updates.len();
+
+        len + self.additions.len()
+            + self.removals.len()
+            + self.reinitializations.len()
+            + self.group_context_extensions.len()
+    }
+
+    /// Iterate over all proposals inside the bundle.
+    pub fn iter_proposals(&self) -> impl Iterator<Item = ProposalInfo<BorrowedProposal<'_>>> {
+        let res = self
+            .additions
+            .iter()
+            .map(|p| p.as_ref().map(BorrowedProposal::Add))
+            .chain(
+                self.removals
+                    .iter()
+                    .map(|p| p.as_ref().map(BorrowedProposal::Remove)),
+            )
+            .chain(
+                self.reinitializations
+                    .iter()
+                    .map(|p| p.as_ref().map(BorrowedProposal::ReInit)),
+            );
+
+        #[cfg(feature = "by_ref_proposal")]
+        let res = res.chain(
+            self.updates
+                .iter()
+                .map(|p| p.as_ref().map(BorrowedProposal::Update)),
+        );
+
+        #[cfg(feature = "psk")]
+        let res = res.chain(
+            self.psks
+                .iter()
+                .map(|p| p.as_ref().map(BorrowedProposal::Psk)),
+        );
+
+        let res = res.chain(
+            self.external_initializations
+                .iter()
+                .map(|p| p.as_ref().map(BorrowedProposal::ExternalInit)),
+        );
+
+        let res = res.chain(
+            self.group_context_extensions
+                .iter()
+                .map(|p| p.as_ref().map(BorrowedProposal::GroupContextExtensions)),
+        );
+
+        #[cfg(feature = "custom_proposal")]
+        let res = res.chain(
+            self.custom_proposals
+                .iter()
+                .map(|p| p.as_ref().map(BorrowedProposal::Custom)),
+        );
+
+        res
+    }
+
+    /// Iterate over proposal in the bundle, consuming the bundle.
+    pub fn into_proposals(self) -> impl Iterator<Item = ProposalInfo<Proposal>> {
+        let res = empty();
+
+        #[cfg(feature = "custom_proposal")]
+        let res = res.chain(
+            self.custom_proposals
+                .into_iter()
+                .map(|p| p.map(Proposal::Custom)),
+        );
+
+        let res = res.chain(
+            self.external_initializations
+                .into_iter()
+                .map(|p| p.map(Proposal::ExternalInit)),
+        );
+
+        #[cfg(feature = "psk")]
+        let res = res.chain(self.psks.into_iter().map(|p| p.map(Proposal::Psk)));
+
+        #[cfg(feature = "by_ref_proposal")]
+        let res = res.chain(self.updates.into_iter().map(|p| p.map(Proposal::Update)));
+
+        res.chain(
+            self.additions
+                .into_iter()
+                .map(|p| p.map(|p| Proposal::Add(alloc::boxed::Box::new(p)))),
+        )
+        .chain(self.removals.into_iter().map(|p| p.map(Proposal::Remove)))
+        .chain(
+            self.reinitializations
+                .into_iter()
+                .map(|p| p.map(Proposal::ReInit)),
+        )
+        .chain(
+            self.group_context_extensions
+                .into_iter()
+                .map(|p| p.map(Proposal::GroupContextExtensions)),
+        )
+    }
+
+    pub(crate) fn into_proposals_or_refs(self) -> Vec<ProposalOrRef> {
+        self.into_proposals()
+            .filter_map(|p| match p.source {
+                ProposalSource::ByValue => Some(ProposalOrRef::Proposal(Box::new(p.proposal))),
+                #[cfg(feature = "by_ref_proposal")]
+                ProposalSource::ByReference(reference) => Some(ProposalOrRef::Reference(reference)),
+                _ => None,
+            })
+            .collect()
+    }
+
+    /// Add proposals in the bundle.
+    pub fn add_proposals(&self) -> &[ProposalInfo<AddProposal>] {
+        &self.additions
+    }
+
+    /// Update proposals in the bundle.
+    #[cfg(feature = "by_ref_proposal")]
+    pub fn update_proposals(&self) -> &[ProposalInfo<UpdateProposal>] {
+        &self.updates
+    }
+
+    /// Senders of update proposals in the bundle.
+    #[cfg(feature = "by_ref_proposal")]
+    pub fn update_proposal_senders(&self) -> &[LeafIndex] {
+        &self.update_senders
+    }
+
+    /// Remove proposals in the bundle.
+    pub fn remove_proposals(&self) -> &[ProposalInfo<RemoveProposal>] {
+        &self.removals
+    }
+
+    /// Pre-shared key proposals in the bundle.
+    #[cfg(feature = "psk")]
+    pub fn psk_proposals(&self) -> &[ProposalInfo<PreSharedKeyProposal>] {
+        &self.psks
+    }
+
+    /// Reinit proposals in the bundle.
+    pub fn reinit_proposals(&self) -> &[ProposalInfo<ReInitProposal>] {
+        &self.reinitializations
+    }
+
+    /// External init proposals in the bundle.
+    pub fn external_init_proposals(&self) -> &[ProposalInfo<ExternalInit>] {
+        &self.external_initializations
+    }
+
+    /// Group context extension proposals in the bundle.
+    pub fn group_context_ext_proposals(&self) -> &[ProposalInfo<ExtensionList>] {
+        &self.group_context_extensions
+    }
+
+    /// Custom proposals in the bundle.
+    #[cfg(feature = "custom_proposal")]
+    pub fn custom_proposals(&self) -> &[ProposalInfo<CustomProposal>] {
+        &self.custom_proposals
+    }
+
+    pub(crate) fn group_context_extensions_proposal(&self) -> Option<&ProposalInfo<ExtensionList>> {
+        self.group_context_extensions.first()
+    }
+
+    /// Custom proposal types that are in use within this bundle.
+    #[cfg(feature = "custom_proposal")]
+    pub fn custom_proposal_types(&self) -> impl Iterator<Item = ProposalType> + '_ {
+        #[cfg(feature = "std")]
+        let res = self
+            .custom_proposals
+            .iter()
+            .map(|v| v.proposal.proposal_type())
+            .unique();
+
+        #[cfg(not(feature = "std"))]
+        let res = self
+            .custom_proposals
+            .iter()
+            .map(|v| v.proposal.proposal_type())
+            .collect::<alloc::collections::BTreeSet<_>>()
+            .into_iter();
+
+        res
+    }
+
+    /// Standard proposal types that are in use within this bundle.
+    pub fn proposal_types(&self) -> impl Iterator<Item = ProposalType> + '_ {
+        let res = (!self.additions.is_empty())
+            .then_some(ProposalType::ADD)
+            .into_iter()
+            .chain((!self.removals.is_empty()).then_some(ProposalType::REMOVE))
+            .chain((!self.reinitializations.is_empty()).then_some(ProposalType::RE_INIT));
+
+        #[cfg(feature = "by_ref_proposal")]
+        let res = res.chain((!self.updates.is_empty()).then_some(ProposalType::UPDATE));
+
+        #[cfg(feature = "psk")]
+        let res = res.chain((!self.psks.is_empty()).then_some(ProposalType::PSK));
+
+        let res = res.chain(
+            (!self.external_initializations.is_empty()).then_some(ProposalType::EXTERNAL_INIT),
+        );
+
+        #[cfg(not(feature = "custom_proposal"))]
+        return res.chain(
+            (!self.group_context_extensions.is_empty())
+                .then_some(ProposalType::GROUP_CONTEXT_EXTENSIONS),
+        );
+
+        #[cfg(feature = "custom_proposal")]
+        return res
+            .chain(
+                (!self.group_context_extensions.is_empty())
+                    .then_some(ProposalType::GROUP_CONTEXT_EXTENSIONS),
+            )
+            .chain(self.custom_proposal_types());
+    }
+}
+
+impl FromIterator<(Proposal, Sender, ProposalSource)> for ProposalBundle {
+    fn from_iter<I>(iter: I) -> Self
+    where
+        I: IntoIterator<Item = (Proposal, Sender, ProposalSource)>,
+    {
+        let mut bundle = ProposalBundle::default();
+        for (proposal, sender, source) in iter {
+            bundle.add(proposal, sender, source);
+        }
+        bundle
+    }
+}
+
+#[cfg(feature = "by_ref_proposal")]
+impl<'a> FromIterator<(&'a ProposalRef, &'a CachedProposal)> for ProposalBundle {
+    fn from_iter<I>(iter: I) -> Self
+    where
+        I: IntoIterator<Item = (&'a ProposalRef, &'a CachedProposal)>,
+    {
+        iter.into_iter()
+            .map(|(r, p)| {
+                (
+                    p.proposal.clone(),
+                    p.sender,
+                    ProposalSource::ByReference(r.clone()),
+                )
+            })
+            .collect()
+    }
+}
+
+#[cfg(feature = "by_ref_proposal")]
+impl<'a> FromIterator<&'a (ProposalRef, CachedProposal)> for ProposalBundle {
+    fn from_iter<I>(iter: I) -> Self
+    where
+        I: IntoIterator<Item = &'a (ProposalRef, CachedProposal)>,
+    {
+        iter.into_iter().map(|pair| (&pair.0, &pair.1)).collect()
+    }
+}
+
+#[cfg_attr(
+    all(feature = "ffi", not(test)),
+    safer_ffi_gen::ffi_type(clone, opaque)
+)]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug, PartialEq)]
+pub enum ProposalSource {
+    ByValue,
+    #[cfg(feature = "by_ref_proposal")]
+    ByReference(ProposalRef),
+    Local,
+}
+
+#[cfg_attr(all(feature = "ffi", not(test)), safer_ffi_gen::ffi_type(opaque))]
+#[derive(Clone, Debug, PartialEq)]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[non_exhaustive]
+/// Proposal description used as input to a
+/// [`MlsRules`](crate::MlsRules).
+pub struct ProposalInfo<T> {
+    /// The underlying proposal value.
+    pub proposal: T,
+    /// The sender of this proposal.
+    pub sender: Sender,
+    /// The source of the proposal.
+    pub source: ProposalSource,
+}
+
+#[cfg_attr(all(feature = "ffi", not(test)), ::safer_ffi_gen::safer_ffi_gen)]
+impl<T> ProposalInfo<T> {
+    /// Create a new ProposalInfo.
+    ///
+    /// The resulting value will be either transmitted with a commit or
+    /// locally injected into a commit resolution depending on the
+    /// `can_transmit` flag.
+    ///
+    /// This function is useful when implementing custom
+    /// [`MlsRules`](crate::MlsRules).
+    #[cfg_attr(all(feature = "ffi", not(test)), safer_ffi_gen::safer_ffi_gen_ignore)]
+    pub fn new(proposal: T, sender: Sender, can_transmit: bool) -> Self {
+        let source = if can_transmit {
+            ProposalSource::ByValue
+        } else {
+            ProposalSource::Local
+        };
+
+        ProposalInfo {
+            proposal,
+            sender,
+            source,
+        }
+    }
+
+    #[cfg(all(feature = "ffi", not(test)))]
+    pub fn sender(&self) -> &Sender {
+        &self.sender
+    }
+
+    #[cfg(all(feature = "ffi", not(test)))]
+    pub fn source(&self) -> &ProposalSource {
+        &self.source
+    }
+
+    #[cfg_attr(all(feature = "ffi", not(test)), safer_ffi_gen::safer_ffi_gen_ignore)]
+    pub fn map<U, F>(self, f: F) -> ProposalInfo<U>
+    where
+        F: FnOnce(T) -> U,
+    {
+        ProposalInfo {
+            proposal: f(self.proposal),
+            sender: self.sender,
+            source: self.source,
+        }
+    }
+
+    #[cfg_attr(all(feature = "ffi", not(test)), safer_ffi_gen::safer_ffi_gen_ignore)]
+    pub fn as_ref(&self) -> ProposalInfo<&T> {
+        ProposalInfo {
+            proposal: &self.proposal,
+            sender: self.sender,
+            source: self.source.clone(),
+        }
+    }
+
+    #[inline(always)]
+    pub fn is_by_value(&self) -> bool {
+        self.source == ProposalSource::ByValue
+    }
+
+    #[inline(always)]
+    pub fn is_by_reference(&self) -> bool {
+        !self.is_by_value()
+    }
+
+    /// The [`ProposalRef`] of this proposal if its source is [`ProposalSource::ByReference`]
+    #[cfg(feature = "by_ref_proposal")]
+    pub fn proposal_ref(&self) -> Option<&ProposalRef> {
+        match self.source {
+            ProposalSource::ByReference(ref reference) => Some(reference),
+            _ => None,
+        }
+    }
+}
+
+#[cfg(all(feature = "ffi", not(test)))]
+safer_ffi_gen::specialize!(ProposalInfoFfi = ProposalInfo<Proposal>);
+
+pub trait Proposable: Sized {
+    const TYPE: ProposalType;
+
+    fn filter(bundle: &ProposalBundle) -> &[ProposalInfo<Self>];
+    fn remove(bundle: &mut ProposalBundle, index: usize);
+    fn retain<F>(bundle: &mut ProposalBundle, keep: F)
+    where
+        F: FnMut(&ProposalInfo<Self>) -> bool;
+}
+
+macro_rules! impl_proposable {
+    ($ty:ty, $proposal_type:ident, $field:ident) => {
+        impl Proposable for $ty {
+            const TYPE: ProposalType = ProposalType::$proposal_type;
+
+            fn filter(bundle: &ProposalBundle) -> &[ProposalInfo<Self>] {
+                &bundle.$field
+            }
+
+            fn remove(bundle: &mut ProposalBundle, index: usize) {
+                if index < bundle.$field.len() {
+                    bundle.$field.remove(index);
+                }
+            }
+
+            fn retain<F>(bundle: &mut ProposalBundle, keep: F)
+            where
+                F: FnMut(&ProposalInfo<Self>) -> bool,
+            {
+                bundle.$field.retain(keep);
+            }
+        }
+    };
+}
+
+impl_proposable!(AddProposal, ADD, additions);
+#[cfg(feature = "by_ref_proposal")]
+impl_proposable!(UpdateProposal, UPDATE, updates);
+impl_proposable!(RemoveProposal, REMOVE, removals);
+#[cfg(feature = "psk")]
+impl_proposable!(PreSharedKeyProposal, PSK, psks);
+impl_proposable!(ReInitProposal, RE_INIT, reinitializations);
+impl_proposable!(ExternalInit, EXTERNAL_INIT, external_initializations);
+impl_proposable!(
+    ExtensionList,
+    GROUP_CONTEXT_EXTENSIONS,
+    group_context_extensions
+);
diff --git a/src/group/proposal_filter/filtering.rs b/src/group/proposal_filter/filtering.rs
new file mode 100644
index 0000000..8e67ff5
--- /dev/null
+++ b/src/group/proposal_filter/filtering.rs
@@ -0,0 +1,580 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// Copyright by contributors to this project.
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+
+use crate::{
+    client::MlsError,
+    group::{
+        proposal::ReInitProposal,
+        proposal_filter::{ProposalBundle, ProposalInfo},
+        AddProposal, ProposalType, RemoveProposal, Sender, UpdateProposal,
+    },
+    iter::wrap_iter,
+    protocol_version::ProtocolVersion,
+    time::MlsTime,
+    tree_kem::{
+        leaf_node_validator::{LeafNodeValidator, ValidationContext},
+        node::LeafIndex,
+        TreeKemPublic,
+    },
+    CipherSuiteProvider, ExtensionList,
+};
+
+use super::filtering_common::{filter_out_invalid_psks, ApplyProposalsOutput, ProposalApplier};
+
+#[cfg(feature = "by_ref_proposal")]
+use crate::extension::ExternalSendersExt;
+
+use alloc::vec::Vec;
+use mls_rs_core::{error::IntoAnyError, identity::IdentityProvider, psk::PreSharedKeyStorage};
+
+#[cfg(any(
+    feature = "custom_proposal",
+    not(any(mls_build_async, feature = "rayon"))
+))]
+use itertools::Itertools;
+
+use crate::group::ExternalInit;
+
+#[cfg(feature = "psk")]
+use crate::group::proposal::PreSharedKeyProposal;
+
+#[cfg(all(not(mls_build_async), feature = "rayon"))]
+use {crate::iter::ParallelIteratorExt, rayon::prelude::*};
+
+#[cfg(mls_build_async)]
+use futures::{StreamExt, TryStreamExt};
+
+impl<'a, C, P, CSP> ProposalApplier<'a, C, P, CSP>
+where
+    C: IdentityProvider,
+    P: PreSharedKeyStorage,
+    CSP: CipherSuiteProvider,
+{
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(super) async fn apply_proposals_from_member(
+        &self,
+        strategy: FilterStrategy,
+        commit_sender: LeafIndex,
+        proposals: ProposalBundle,
+        commit_time: Option<MlsTime>,
+    ) -> Result<ApplyProposalsOutput, MlsError> {
+        let proposals = filter_out_invalid_proposers(strategy, proposals)?;
+
+        let mut proposals: ProposalBundle =
+            filter_out_update_for_committer(strategy, commit_sender, proposals)?;
+
+        // We ignore the strategy here because the check above ensures all updates are from members
+        proposals.update_senders = proposals
+            .updates
+            .iter()
+            .map(leaf_index_of_update_sender)
+            .collect::<Result<_, _>>()?;
+
+        let mut proposals = filter_out_removal_of_committer(strategy, commit_sender, proposals)?;
+
+        filter_out_invalid_psks(
+            strategy,
+            self.cipher_suite_provider,
+            &mut proposals,
+            self.psk_storage,
+        )
+        .await?;
+
+        #[cfg(feature = "by_ref_proposal")]
+        let proposals = filter_out_invalid_group_extensions(
+            strategy,
+            proposals,
+            self.identity_provider,
+            commit_time,
+        )
+        .await?;
+
+        let proposals = filter_out_extra_group_context_extensions(strategy, proposals)?;
+        let proposals = filter_out_invalid_reinit(strategy, proposals, self.protocol_version)?;
+        let proposals = filter_out_reinit_if_other_proposals(strategy.is_ignore(), proposals)?;
+
+        let proposals = filter_out_external_init(strategy, proposals)?;
+
+        self.apply_proposal_changes(strategy, proposals, commit_time)
+            .await
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(super) async fn apply_proposal_changes(
+        &self,
+        strategy: FilterStrategy,
+        proposals: ProposalBundle,
+        commit_time: Option<MlsTime>,
+    ) -> Result<ApplyProposalsOutput, MlsError> {
+        match proposals.group_context_extensions_proposal().cloned() {
+            Some(p) => {
+                self.apply_proposals_with_new_capabilities(strategy, proposals, p, commit_time)
+                    .await
+            }
+            None => {
+                self.apply_tree_changes(
+                    strategy,
+                    proposals,
+                    self.original_group_extensions,
+                    commit_time,
+                )
+                .await
+            }
+        }
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(super) async fn apply_tree_changes(
+        &self,
+        strategy: FilterStrategy,
+        proposals: ProposalBundle,
+        group_extensions_in_use: &ExtensionList,
+        commit_time: Option<MlsTime>,
+    ) -> Result<ApplyProposalsOutput, MlsError> {
+        let mut applied_proposals = self
+            .validate_new_nodes(strategy, proposals, group_extensions_in_use, commit_time)
+            .await?;
+
+        let mut new_tree = self.original_tree.clone();
+
+        let added = new_tree
+            .batch_edit(
+                &mut applied_proposals,
+                group_extensions_in_use,
+                self.identity_provider,
+                self.cipher_suite_provider,
+                strategy.is_ignore(),
+            )
+            .await?;
+
+        let new_context_extensions = applied_proposals
+            .group_context_extensions_proposal()
+            .map(|gce| gce.proposal.clone());
+
+        Ok(ApplyProposalsOutput {
+            applied_proposals,
+            new_tree,
+            indexes_of_added_kpkgs: added,
+            external_init_index: None,
+            new_context_extensions,
+        })
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn validate_new_nodes(
+        &self,
+        strategy: FilterStrategy,
+        mut proposals: ProposalBundle,
+        group_extensions_in_use: &ExtensionList,
+        commit_time: Option<MlsTime>,
+    ) -> Result<ProposalBundle, MlsError> {
+        let leaf_node_validator = &LeafNodeValidator::new(
+            self.cipher_suite_provider,
+            self.identity_provider,
+            Some(group_extensions_in_use),
+        );
+
+        let bad_indices: Vec<_> = wrap_iter(proposals.update_proposals())
+            .zip(wrap_iter(proposals.update_proposal_senders()))
+            .enumerate()
+            .filter_map(|(i, (p, &sender_index))| async move {
+                let res = {
+                    let leaf = &p.proposal.leaf_node;
+
+                    let res = leaf_node_validator
+                        .check_if_valid(
+                            leaf,
+                            ValidationContext::Update((self.group_id, *sender_index, commit_time)),
+                        )
+                        .await;
+
+                    let old_leaf = match self.original_tree.get_leaf_node(sender_index) {
+                        Ok(leaf) => leaf,
+                        Err(e) => return Some(Err(e)),
+                    };
+
+                    let valid_successor = self
+                        .identity_provider
+                        .valid_successor(
+                            &old_leaf.signing_identity,
+                            &leaf.signing_identity,
+                            group_extensions_in_use,
+                        )
+                        .await
+                        .map_err(|e| MlsError::IdentityProviderError(e.into_any_error()))
+                        .and_then(|valid| valid.then_some(()).ok_or(MlsError::InvalidSuccessor));
+
+                    res.and(valid_successor)
+                };
+
+                apply_strategy(strategy, p.is_by_reference(), res)
+                    .map(|b| (!b).then_some(i))
+                    .transpose()
+            })
+            .try_collect()
+            .await?;
+
+        bad_indices.into_iter().rev().for_each(|i| {
+            proposals.remove::<UpdateProposal>(i);
+            proposals.update_senders.remove(i);
+        });
+
+        let bad_indices: Vec<_> = wrap_iter(proposals.add_proposals())
+            .enumerate()
+            .filter_map(|(i, p)| async move {
+                let res = self
+                    .validate_new_node(leaf_node_validator, &p.proposal.key_package, commit_time)
+                    .await;
+
+                apply_strategy(strategy, p.is_by_reference(), res)
+                    .map(|b| (!b).then_some(i))
+                    .transpose()
+            })
+            .try_collect()
+            .await?;
+
+        bad_indices
+            .into_iter()
+            .rev()
+            .for_each(|i| proposals.remove::<AddProposal>(i));
+
+        Ok(proposals)
+    }
+}
+
+#[derive(Clone, Copy, Debug)]
+pub enum FilterStrategy {
+    IgnoreByRef,
+    IgnoreNone,
+}
+
+impl FilterStrategy {
+    pub(super) fn ignore(self, by_ref: bool) -> bool {
+        match self {
+            FilterStrategy::IgnoreByRef => by_ref,
+            FilterStrategy::IgnoreNone => false,
+        }
+    }
+
+    fn is_ignore(self) -> bool {
+        match self {
+            FilterStrategy::IgnoreByRef => true,
+            FilterStrategy::IgnoreNone => false,
+        }
+    }
+}
+
+pub(crate) fn apply_strategy(
+    strategy: FilterStrategy,
+    by_ref: bool,
+    r: Result<(), MlsError>,
+) -> Result<bool, MlsError> {
+    r.map(|_| true)
+        .or_else(|error| strategy.ignore(by_ref).then_some(false).ok_or(error))
+}
+
+fn filter_out_update_for_committer(
+    strategy: FilterStrategy,
+    commit_sender: LeafIndex,
+    mut proposals: ProposalBundle,
+) -> Result<ProposalBundle, MlsError> {
+    proposals.retain_by_type::<UpdateProposal, _, _>(|p| {
+        apply_strategy(
+            strategy,
+            p.is_by_reference(),
+            (p.sender != Sender::Member(*commit_sender))
+                .then_some(())
+                .ok_or(MlsError::InvalidCommitSelfUpdate),
+        )
+    })?;
+    Ok(proposals)
+}
+
+fn filter_out_removal_of_committer(
+    strategy: FilterStrategy,
+    commit_sender: LeafIndex,
+    mut proposals: ProposalBundle,
+) -> Result<ProposalBundle, MlsError> {
+    proposals.retain_by_type::<RemoveProposal, _, _>(|p| {
+        apply_strategy(
+            strategy,
+            p.is_by_reference(),
+            (p.proposal.to_remove != commit_sender)
+                .then_some(())
+                .ok_or(MlsError::CommitterSelfRemoval),
+        )
+    })?;
+    Ok(proposals)
+}
+
+#[cfg(feature = "by_ref_proposal")]
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+async fn filter_out_invalid_group_extensions<C>(
+    strategy: FilterStrategy,
+    mut proposals: ProposalBundle,
+    identity_provider: &C,
+    commit_time: Option<MlsTime>,
+) -> Result<ProposalBundle, MlsError>
+where
+    C: IdentityProvider,
+{
+    let mut bad_indices = Vec::new();
+
+    for (i, p) in proposals.by_type::<ExtensionList>().enumerate() {
+        let ext = p.proposal.get_as::<ExternalSendersExt>();
+
+        let res = match ext {
+            Ok(None) => Ok(()),
+            Ok(Some(extension)) => extension
+                .verify_all(identity_provider, commit_time, &p.proposal)
+                .await
+                .map_err(|e| MlsError::IdentityProviderError(e.into_any_error())),
+            Err(e) => Err(MlsError::from(e)),
+        };
+
+        if !apply_strategy(strategy, p.is_by_reference(), res)? {
+            bad_indices.push(i);
+        }
+    }
+
+    bad_indices
+        .into_iter()
+        .rev()
+        .for_each(|i| proposals.remove::<ExtensionList>(i));
+
+    Ok(proposals)
+}
+
+fn filter_out_extra_group_context_extensions(
+    strategy: FilterStrategy,
+    mut proposals: ProposalBundle,
+) -> Result<ProposalBundle, MlsError> {
+    let mut found = false;
+
+    proposals.retain_by_type::<ExtensionList, _, _>(|p| {
+        apply_strategy(
+            strategy,
+            p.is_by_reference(),
+            (!core::mem::replace(&mut found, true))
+                .then_some(())
+                .ok_or(MlsError::MoreThanOneGroupContextExtensionsProposal),
+        )
+    })?;
+
+    Ok(proposals)
+}
+
+fn filter_out_invalid_reinit(
+    strategy: FilterStrategy,
+    mut proposals: ProposalBundle,
+    protocol_version: ProtocolVersion,
+) -> Result<ProposalBundle, MlsError> {
+    proposals.retain_by_type::<ReInitProposal, _, _>(|p| {
+        apply_strategy(
+            strategy,
+            p.is_by_reference(),
+            (p.proposal.version >= protocol_version)
+                .then_some(())
+                .ok_or(MlsError::InvalidProtocolVersionInReInit),
+        )
+    })?;
+
+    Ok(proposals)
+}
+
+fn filter_out_reinit_if_other_proposals(
+    filter: bool,
+    mut proposals: ProposalBundle,
+) -> Result<ProposalBundle, MlsError> {
+    let proposal_count = proposals.length();
+
+    let has_reinit_and_other_proposal =
+        !proposals.reinit_proposals().is_empty() && proposal_count != 1;
+
+    if has_reinit_and_other_proposal {
+        let any_by_val = proposals.reinit_proposals().iter().any(|p| p.is_by_value());
+
+        if any_by_val || !filter {
+            return Err(MlsError::OtherProposalWithReInit);
+        }
+
+        let has_other_proposal_type = proposal_count > proposals.reinit_proposals().len();
+
+        if has_other_proposal_type {
+            proposals.reinitializations = Vec::new();
+        } else {
+            proposals.reinitializations.truncate(1);
+        }
+    }
+
+    Ok(proposals)
+}
+
+fn filter_out_external_init(
+    strategy: FilterStrategy,
+    mut proposals: ProposalBundle,
+) -> Result<ProposalBundle, MlsError> {
+    proposals.retain_by_type::<ExternalInit, _, _>(|p| {
+        apply_strategy(
+            strategy,
+            p.is_by_reference(),
+            Err(MlsError::InvalidProposalTypeForSender),
+        )
+    })?;
+
+    Ok(proposals)
+}
+
+pub(crate) fn proposer_can_propose(
+    proposer: Sender,
+    proposal_type: ProposalType,
+    by_ref: bool,
+) -> Result<(), MlsError> {
+    let can_propose = match (proposer, by_ref) {
+        (Sender::Member(_), false) => matches!(
+            proposal_type,
+            ProposalType::ADD
+                | ProposalType::REMOVE
+                | ProposalType::PSK
+                | ProposalType::RE_INIT
+                | ProposalType::GROUP_CONTEXT_EXTENSIONS
+        ),
+        (Sender::Member(_), true) => matches!(
+            proposal_type,
+            ProposalType::ADD
+                | ProposalType::UPDATE
+                | ProposalType::REMOVE
+                | ProposalType::PSK
+                | ProposalType::RE_INIT
+                | ProposalType::GROUP_CONTEXT_EXTENSIONS
+        ),
+        #[cfg(feature = "by_ref_proposal")]
+        (Sender::External(_), false) => false,
+        #[cfg(feature = "by_ref_proposal")]
+        (Sender::External(_), true) => matches!(
+            proposal_type,
+            ProposalType::ADD
+                | ProposalType::REMOVE
+                | ProposalType::RE_INIT
+                | ProposalType::PSK
+                | ProposalType::GROUP_CONTEXT_EXTENSIONS
+        ),
+        (Sender::NewMemberCommit, false) => matches!(
+            proposal_type,
+            ProposalType::REMOVE | ProposalType::PSK | ProposalType::EXTERNAL_INIT
+        ),
+        (Sender::NewMemberCommit, true) => false,
+        (Sender::NewMemberProposal, false) => false,
+        (Sender::NewMemberProposal, true) => matches!(proposal_type, ProposalType::ADD),
+    };
+
+    can_propose
+        .then_some(())
+        .ok_or(MlsError::InvalidProposalTypeForSender)
+}
+
+pub(crate) fn filter_out_invalid_proposers(
+    strategy: FilterStrategy,
+    mut proposals: ProposalBundle,
+) -> Result<ProposalBundle, MlsError> {
+    for i in (0..proposals.add_proposals().len()).rev() {
+        let p = &proposals.add_proposals()[i];
+        let res = proposer_can_propose(p.sender, ProposalType::ADD, p.is_by_reference());
+
+        if !apply_strategy(strategy, p.is_by_reference(), res)? {
+            proposals.remove::<AddProposal>(i);
+        }
+    }
+
+    for i in (0..proposals.update_proposals().len()).rev() {
+        let p = &proposals.update_proposals()[i];
+        let res = proposer_can_propose(p.sender, ProposalType::UPDATE, p.is_by_reference());
+
+        if !apply_strategy(strategy, p.is_by_reference(), res)? {
+            proposals.remove::<UpdateProposal>(i);
+            proposals.update_senders.remove(i);
+        }
+    }
+
+    for i in (0..proposals.remove_proposals().len()).rev() {
+        let p = &proposals.remove_proposals()[i];
+        let res = proposer_can_propose(p.sender, ProposalType::REMOVE, p.is_by_reference());
+
+        if !apply_strategy(strategy, p.is_by_reference(), res)? {
+            proposals.remove::<RemoveProposal>(i);
+        }
+    }
+
+    #[cfg(feature = "psk")]
+    for i in (0..proposals.psk_proposals().len()).rev() {
+        let p = &proposals.psk_proposals()[i];
+        let res = proposer_can_propose(p.sender, ProposalType::PSK, p.is_by_reference());
+
+        if !apply_strategy(strategy, p.is_by_reference(), res)? {
+            proposals.remove::<PreSharedKeyProposal>(i);
+        }
+    }
+
+    for i in (0..proposals.reinit_proposals().len()).rev() {
+        let p = &proposals.reinit_proposals()[i];
+        let res = proposer_can_propose(p.sender, ProposalType::RE_INIT, p.is_by_reference());
+
+        if !apply_strategy(strategy, p.is_by_reference(), res)? {
+            proposals.remove::<ReInitProposal>(i);
+        }
+    }
+
+    for i in (0..proposals.external_init_proposals().len()).rev() {
+        let p = &proposals.external_init_proposals()[i];
+        let res = proposer_can_propose(p.sender, ProposalType::EXTERNAL_INIT, p.is_by_reference());
+
+        if !apply_strategy(strategy, p.is_by_reference(), res)? {
+            proposals.remove::<ExternalInit>(i);
+        }
+    }
+
+    for i in (0..proposals.group_context_ext_proposals().len()).rev() {
+        let p = &proposals.group_context_ext_proposals()[i];
+        let gce_type = ProposalType::GROUP_CONTEXT_EXTENSIONS;
+        let res = proposer_can_propose(p.sender, gce_type, p.is_by_reference());
+
+        if !apply_strategy(strategy, p.is_by_reference(), res)? {
+            proposals.remove::<ExtensionList>(i);
+        }
+    }
+
+    Ok(proposals)
+}
+
+fn leaf_index_of_update_sender(p: &ProposalInfo<UpdateProposal>) -> Result<LeafIndex, MlsError> {
+    match p.sender {
+        Sender::Member(i) => Ok(LeafIndex(i)),
+        _ => Err(MlsError::InvalidProposalTypeForSender),
+    }
+}
+
+#[cfg(feature = "custom_proposal")]
+pub(super) fn filter_out_unsupported_custom_proposals(
+    proposals: &mut ProposalBundle,
+    tree: &TreeKemPublic,
+    strategy: FilterStrategy,
+) -> Result<(), MlsError> {
+    let supported_types = proposals
+        .custom_proposal_types()
+        .filter(|t| tree.can_support_proposal(*t))
+        .collect_vec();
+
+    proposals.retain_custom(|p| {
+        let proposal_type = p.proposal.proposal_type();
+
+        apply_strategy(
+            strategy,
+            p.is_by_reference(),
+            supported_types
+                .contains(&proposal_type)
+                .then_some(())
+                .ok_or(MlsError::UnsupportedCustomProposal(proposal_type)),
+        )
+    })
+}
diff --git a/src/group/proposal_filter/filtering_common.rs b/src/group/proposal_filter/filtering_common.rs
new file mode 100644
index 0000000..278c0de
--- /dev/null
+++ b/src/group/proposal_filter/filtering_common.rs
@@ -0,0 +1,579 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// Copyright by contributors to this project.
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+
+use crate::{
+    client::MlsError,
+    group::{proposal_filter::ProposalBundle, Sender},
+    key_package::{validate_key_package_properties, KeyPackage},
+    protocol_version::ProtocolVersion,
+    time::MlsTime,
+    tree_kem::{
+        leaf_node_validator::{LeafNodeValidator, ValidationContext},
+        node::LeafIndex,
+        TreeKemPublic,
+    },
+    CipherSuiteProvider, ExtensionList,
+};
+
+use crate::tree_kem::leaf_node::LeafNode;
+
+use super::ProposalInfo;
+
+use crate::extension::{MlsExtension, RequiredCapabilitiesExt};
+
+#[cfg(feature = "by_ref_proposal")]
+use crate::extension::ExternalSendersExt;
+
+use mls_rs_core::error::IntoAnyError;
+
+use alloc::vec::Vec;
+use mls_rs_core::{identity::IdentityProvider, psk::PreSharedKeyStorage};
+
+use crate::group::{ExternalInit, ProposalType, RemoveProposal};
+
+#[cfg(all(feature = "by_ref_proposal", feature = "psk"))]
+use crate::group::proposal::PreSharedKeyProposal;
+
+#[cfg(feature = "psk")]
+use crate::group::{JustPreSharedKeyID, ResumptionPSKUsage, ResumptionPsk};
+
+#[cfg(all(feature = "std", feature = "psk"))]
+use std::collections::HashSet;
+
+#[cfg(feature = "by_ref_proposal")]
+use super::filtering::{apply_strategy, filter_out_invalid_proposers, FilterStrategy};
+
+#[cfg(feature = "custom_proposal")]
+use super::filtering::filter_out_unsupported_custom_proposals;
+
+#[derive(Debug)]
+pub(crate) struct ProposalApplier<'a, C, P, CSP> {
+    pub original_tree: &'a TreeKemPublic,
+    pub protocol_version: ProtocolVersion,
+    pub cipher_suite_provider: &'a CSP,
+    pub original_group_extensions: &'a ExtensionList,
+    pub external_leaf: Option<&'a LeafNode>,
+    pub identity_provider: &'a C,
+    pub psk_storage: &'a P,
+    #[cfg(feature = "by_ref_proposal")]
+    pub group_id: &'a [u8],
+}
+
+#[derive(Debug)]
+pub(crate) struct ApplyProposalsOutput {
+    pub(crate) new_tree: TreeKemPublic,
+    pub(crate) indexes_of_added_kpkgs: Vec<LeafIndex>,
+    pub(crate) external_init_index: Option<LeafIndex>,
+    #[cfg(feature = "by_ref_proposal")]
+    pub(crate) applied_proposals: ProposalBundle,
+    pub(crate) new_context_extensions: Option<ExtensionList>,
+}
+
+impl<'a, C, P, CSP> ProposalApplier<'a, C, P, CSP>
+where
+    C: IdentityProvider,
+    P: PreSharedKeyStorage,
+    CSP: CipherSuiteProvider,
+{
+    #[allow(clippy::too_many_arguments)]
+    pub(crate) fn new(
+        original_tree: &'a TreeKemPublic,
+        protocol_version: ProtocolVersion,
+        cipher_suite_provider: &'a CSP,
+        original_group_extensions: &'a ExtensionList,
+        external_leaf: Option<&'a LeafNode>,
+        identity_provider: &'a C,
+        psk_storage: &'a P,
+        #[cfg(feature = "by_ref_proposal")] group_id: &'a [u8],
+    ) -> Self {
+        Self {
+            original_tree,
+            protocol_version,
+            cipher_suite_provider,
+            original_group_extensions,
+            external_leaf,
+            identity_provider,
+            psk_storage,
+            #[cfg(feature = "by_ref_proposal")]
+            group_id,
+        }
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(crate) async fn apply_proposals(
+        &self,
+        #[cfg(feature = "by_ref_proposal")] strategy: FilterStrategy,
+        commit_sender: &Sender,
+        #[cfg(not(feature = "by_ref_proposal"))] proposals: &ProposalBundle,
+        #[cfg(feature = "by_ref_proposal")] proposals: ProposalBundle,
+        commit_time: Option<MlsTime>,
+    ) -> Result<ApplyProposalsOutput, MlsError> {
+        let output = match commit_sender {
+            Sender::Member(sender) => {
+                self.apply_proposals_from_member(
+                    #[cfg(feature = "by_ref_proposal")]
+                    strategy,
+                    LeafIndex(*sender),
+                    proposals,
+                    commit_time,
+                )
+                .await
+            }
+            Sender::NewMemberCommit => {
+                self.apply_proposals_from_new_member(proposals, commit_time)
+                    .await
+            }
+            #[cfg(feature = "by_ref_proposal")]
+            Sender::External(_) => Err(MlsError::ExternalSenderCannotCommit),
+            #[cfg(feature = "by_ref_proposal")]
+            Sender::NewMemberProposal => Err(MlsError::ExternalSenderCannotCommit),
+        }?;
+
+        #[cfg(all(feature = "by_ref_proposal", feature = "custom_proposal"))]
+        let mut output = output;
+
+        #[cfg(all(feature = "by_ref_proposal", feature = "custom_proposal"))]
+        filter_out_unsupported_custom_proposals(
+            &mut output.applied_proposals,
+            &output.new_tree,
+            strategy,
+        )?;
+
+        #[cfg(all(not(feature = "by_ref_proposal"), feature = "custom_proposal"))]
+        filter_out_unsupported_custom_proposals(proposals, &output.new_tree)?;
+
+        Ok(output)
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    // The lint below is triggered by the `proposals` parameter which may or may not be a borrow.
+    #[allow(clippy::needless_borrow)]
+    async fn apply_proposals_from_new_member(
+        &self,
+        #[cfg(not(feature = "by_ref_proposal"))] proposals: &ProposalBundle,
+        #[cfg(feature = "by_ref_proposal")] proposals: ProposalBundle,
+        commit_time: Option<MlsTime>,
+    ) -> Result<ApplyProposalsOutput, MlsError> {
+        let external_leaf = self
+            .external_leaf
+            .ok_or(MlsError::ExternalCommitMustHaveNewLeaf)?;
+
+        ensure_exactly_one_external_init(&proposals)?;
+
+        ensure_at_most_one_removal_for_self(
+            &proposals,
+            external_leaf,
+            self.original_tree,
+            self.identity_provider,
+            self.original_group_extensions,
+        )
+        .await?;
+
+        ensure_proposals_in_external_commit_are_allowed(&proposals)?;
+        ensure_no_proposal_by_ref(&proposals)?;
+
+        #[cfg(feature = "by_ref_proposal")]
+        let mut proposals = filter_out_invalid_proposers(FilterStrategy::IgnoreNone, proposals)?;
+
+        filter_out_invalid_psks(
+            #[cfg(feature = "by_ref_proposal")]
+            FilterStrategy::IgnoreNone,
+            self.cipher_suite_provider,
+            #[cfg(feature = "by_ref_proposal")]
+            &mut proposals,
+            #[cfg(not(feature = "by_ref_proposal"))]
+            proposals,
+            self.psk_storage,
+        )
+        .await?;
+
+        let mut output = self
+            .apply_proposal_changes(
+                #[cfg(feature = "by_ref_proposal")]
+                FilterStrategy::IgnoreNone,
+                proposals,
+                commit_time,
+            )
+            .await?;
+
+        output.external_init_index = Some(
+            insert_external_leaf(
+                &mut output.new_tree,
+                external_leaf.clone(),
+                self.identity_provider,
+                self.original_group_extensions,
+            )
+            .await?,
+        );
+
+        Ok(output)
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(super) async fn apply_proposals_with_new_capabilities(
+        &self,
+        #[cfg(feature = "by_ref_proposal")] strategy: FilterStrategy,
+        #[cfg(not(feature = "by_ref_proposal"))] proposals: &ProposalBundle,
+        #[cfg(feature = "by_ref_proposal")] proposals: ProposalBundle,
+        group_context_extensions_proposal: ProposalInfo<ExtensionList>,
+        commit_time: Option<MlsTime>,
+    ) -> Result<ApplyProposalsOutput, MlsError>
+    where
+        C: IdentityProvider,
+    {
+        #[cfg(feature = "by_ref_proposal")]
+        let mut proposals_clone = proposals.clone();
+
+        // Apply adds, updates etc. in the context of new extensions
+        let output = self
+            .apply_tree_changes(
+                #[cfg(feature = "by_ref_proposal")]
+                strategy,
+                proposals,
+                &group_context_extensions_proposal.proposal,
+                commit_time,
+            )
+            .await?;
+
+        // Verify that capabilities and extensions are supported after modifications.
+        // TODO: The newly inserted nodes have already been validated by `apply_tree_changes`
+        // above. We should investigate if there is an easy way to avoid the double check.
+        let must_check = group_context_extensions_proposal
+            .proposal
+            .has_extension(RequiredCapabilitiesExt::extension_type());
+
+        #[cfg(feature = "by_ref_proposal")]
+        let must_check = must_check
+            || group_context_extensions_proposal
+                .proposal
+                .has_extension(ExternalSendersExt::extension_type());
+
+        let new_capabilities_supported = if must_check {
+            let leaf_validator = LeafNodeValidator::new(
+                self.cipher_suite_provider,
+                self.identity_provider,
+                Some(&group_context_extensions_proposal.proposal),
+            );
+
+            output
+                .new_tree
+                .non_empty_leaves()
+                .try_for_each(|(_, leaf)| {
+                    leaf_validator.validate_required_capabilities(leaf)?;
+
+                    #[cfg(feature = "by_ref_proposal")]
+                    leaf_validator.validate_external_senders_ext_credentials(leaf)?;
+
+                    Ok(())
+                })
+        } else {
+            Ok(())
+        };
+
+        let new_extensions_supported = group_context_extensions_proposal
+            .proposal
+            .iter()
+            .map(|extension| extension.extension_type)
+            .filter(|&ext_type| !ext_type.is_default())
+            .find(|ext_type| {
+                !output
+                    .new_tree
+                    .non_empty_leaves()
+                    .all(|(_, leaf)| leaf.capabilities.extensions.contains(ext_type))
+            })
+            .map_or(Ok(()), |ext| Err(MlsError::UnsupportedGroupExtension(ext)));
+
+        #[cfg(not(feature = "by_ref_proposal"))]
+        {
+            new_capabilities_supported.and(new_extensions_supported)?;
+            Ok(output)
+        }
+
+        #[cfg(feature = "by_ref_proposal")]
+        // If extensions are good, return `Ok`. If not and the strategy is to filter, remove the group
+        // context extensions proposal and try applying all proposals again in the context of the old
+        // extensions. Else, return an error.
+        match new_capabilities_supported.and(new_extensions_supported) {
+            Ok(()) => Ok(output),
+            Err(e) => {
+                if strategy.ignore(group_context_extensions_proposal.is_by_reference()) {
+                    proposals_clone.group_context_extensions.clear();
+
+                    self.apply_tree_changes(
+                        strategy,
+                        proposals_clone,
+                        self.original_group_extensions,
+                        commit_time,
+                    )
+                    .await
+                } else {
+                    Err(e)
+                }
+            }
+        }
+    }
+
+    #[cfg(any(mls_build_async, not(feature = "rayon")))]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn validate_new_node<Ip: IdentityProvider, Cp: CipherSuiteProvider>(
+        &self,
+        leaf_node_validator: &LeafNodeValidator<'_, Ip, Cp>,
+        key_package: &KeyPackage,
+        commit_time: Option<MlsTime>,
+    ) -> Result<(), MlsError> {
+        leaf_node_validator
+            .check_if_valid(&key_package.leaf_node, ValidationContext::Add(commit_time))
+            .await?;
+
+        validate_key_package_properties(
+            key_package,
+            self.protocol_version,
+            self.cipher_suite_provider,
+        )
+        .await
+    }
+
+    #[cfg(all(not(mls_build_async), feature = "rayon"))]
+    pub fn validate_new_node<Ip: IdentityProvider, Cp: CipherSuiteProvider>(
+        &self,
+        leaf_node_validator: &LeafNodeValidator<'_, Ip, Cp>,
+        key_package: &KeyPackage,
+        commit_time: Option<MlsTime>,
+    ) -> Result<(), MlsError> {
+        let (a, b) = rayon::join(
+            || {
+                leaf_node_validator
+                    .check_if_valid(&key_package.leaf_node, ValidationContext::Add(commit_time))
+            },
+            || {
+                validate_key_package_properties(
+                    key_package,
+                    self.protocol_version,
+                    self.cipher_suite_provider,
+                )
+            },
+        );
+        a?;
+        b
+    }
+}
+
+#[cfg(feature = "psk")]
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+pub(crate) async fn filter_out_invalid_psks<P, CP>(
+    #[cfg(feature = "by_ref_proposal")] strategy: FilterStrategy,
+    cipher_suite_provider: &CP,
+    #[cfg(not(feature = "by_ref_proposal"))] proposals: &ProposalBundle,
+    #[cfg(feature = "by_ref_proposal")] proposals: &mut ProposalBundle,
+    psk_storage: &P,
+) -> Result<(), MlsError>
+where
+    P: PreSharedKeyStorage,
+    CP: CipherSuiteProvider,
+{
+    let kdf_extract_size = cipher_suite_provider.kdf_extract_size();
+
+    #[cfg(feature = "std")]
+    let mut ids_seen = HashSet::new();
+
+    #[cfg(not(feature = "std"))]
+    let mut ids_seen = Vec::new();
+
+    #[cfg(feature = "by_ref_proposal")]
+    let mut bad_indices = Vec::new();
+
+    for i in 0..proposals.psk_proposals().len() {
+        let p = &proposals.psks[i];
+
+        let valid = matches!(
+            p.proposal.psk.key_id,
+            JustPreSharedKeyID::External(_)
+                | JustPreSharedKeyID::Resumption(ResumptionPsk {
+                    usage: ResumptionPSKUsage::Application,
+                    ..
+                })
+        );
+
+        let nonce_length = p.proposal.psk.psk_nonce.0.len();
+        let nonce_valid = nonce_length == kdf_extract_size;
+
+        #[cfg(feature = "std")]
+        let is_new_id = ids_seen.insert(p.proposal.psk.clone());
+
+        #[cfg(not(feature = "std"))]
+        let is_new_id = !ids_seen.contains(&p.proposal.psk);
+
+        let external_id_is_valid = match &p.proposal.psk.key_id {
+            JustPreSharedKeyID::External(id) => psk_storage
+                .contains(id)
+                .await
+                .map_err(|e| MlsError::PskStoreError(e.into_any_error()))
+                .and_then(|found| {
+                    if found {
+                        Ok(())
+                    } else {
+                        Err(MlsError::MissingRequiredPsk)
+                    }
+                }),
+            JustPreSharedKeyID::Resumption(_) => Ok(()),
+        };
+
+        #[cfg(not(feature = "by_ref_proposal"))]
+        if !valid {
+            return Err(MlsError::InvalidTypeOrUsageInPreSharedKeyProposal);
+        } else if !nonce_valid {
+            return Err(MlsError::InvalidPskNonceLength);
+        } else if !is_new_id {
+            return Err(MlsError::DuplicatePskIds);
+        } else if external_id_is_valid.is_err() {
+            return external_id_is_valid;
+        }
+
+        #[cfg(feature = "by_ref_proposal")]
+        {
+            let res = if !valid {
+                Err(MlsError::InvalidTypeOrUsageInPreSharedKeyProposal)
+            } else if !nonce_valid {
+                Err(MlsError::InvalidPskNonceLength)
+            } else if !is_new_id {
+                Err(MlsError::DuplicatePskIds)
+            } else {
+                external_id_is_valid
+            };
+
+            if !apply_strategy(strategy, p.is_by_reference(), res)? {
+                bad_indices.push(i)
+            }
+        }
+
+        #[cfg(not(feature = "std"))]
+        ids_seen.push(p.proposal.psk.clone());
+    }
+
+    #[cfg(feature = "by_ref_proposal")]
+    bad_indices
+        .into_iter()
+        .rev()
+        .for_each(|i| proposals.remove::<PreSharedKeyProposal>(i));
+
+    Ok(())
+}
+
+#[cfg(not(feature = "psk"))]
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+pub(crate) async fn filter_out_invalid_psks<P, CP>(
+    #[cfg(feature = "by_ref_proposal")] _: FilterStrategy,
+    _: &CP,
+    #[cfg(not(feature = "by_ref_proposal"))] _: &ProposalBundle,
+    #[cfg(feature = "by_ref_proposal")] _: &mut ProposalBundle,
+    _: &P,
+) -> Result<(), MlsError>
+where
+    P: PreSharedKeyStorage,
+    CP: CipherSuiteProvider,
+{
+    Ok(())
+}
+
+fn ensure_exactly_one_external_init(proposals: &ProposalBundle) -> Result<(), MlsError> {
+    (proposals.by_type::<ExternalInit>().count() == 1)
+        .then_some(())
+        .ok_or(MlsError::ExternalCommitMustHaveExactlyOneExternalInit)
+}
+
+/// Non-default proposal types are by default allowed. Custom MlsRules may disallow
+/// specific custom proposals in external commits
+fn ensure_proposals_in_external_commit_are_allowed(
+    proposals: &ProposalBundle,
+) -> Result<(), MlsError> {
+    let supported_default_types = [
+        ProposalType::EXTERNAL_INIT,
+        ProposalType::REMOVE,
+        ProposalType::PSK,
+    ];
+
+    let unsupported_type = proposals
+        .proposal_types()
+        .find(|ty| !supported_default_types.contains(ty) && ProposalType::DEFAULT.contains(ty));
+
+    match unsupported_type {
+        Some(kind) => Err(MlsError::InvalidProposalTypeInExternalCommit(kind)),
+        None => Ok(()),
+    }
+}
+
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+async fn ensure_at_most_one_removal_for_self<C>(
+    proposals: &ProposalBundle,
+    external_leaf: &LeafNode,
+    tree: &TreeKemPublic,
+    identity_provider: &C,
+    extensions: &ExtensionList,
+) -> Result<(), MlsError>
+where
+    C: IdentityProvider,
+{
+    let mut removals = proposals.by_type::<RemoveProposal>();
+
+    match (removals.next(), removals.next()) {
+        (Some(removal), None) => {
+            ensure_removal_is_for_self(
+                &removal.proposal,
+                external_leaf,
+                tree,
+                identity_provider,
+                extensions,
+            )
+            .await
+        }
+        (Some(_), Some(_)) => Err(MlsError::ExternalCommitWithMoreThanOneRemove),
+        (None, _) => Ok(()),
+    }
+}
+
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+async fn ensure_removal_is_for_self<C>(
+    removal: &RemoveProposal,
+    external_leaf: &LeafNode,
+    tree: &TreeKemPublic,
+    identity_provider: &C,
+    extensions: &ExtensionList,
+) -> Result<(), MlsError>
+where
+    C: IdentityProvider,
+{
+    let existing_signing_id = &tree.get_leaf_node(removal.to_remove)?.signing_identity;
+
+    identity_provider
+        .valid_successor(
+            existing_signing_id,
+            &external_leaf.signing_identity,
+            extensions,
+        )
+        .await
+        .map_err(|e| MlsError::IdentityProviderError(e.into_any_error()))?
+        .then_some(())
+        .ok_or(MlsError::ExternalCommitRemovesOtherIdentity)
+}
+
+/// Non-default by-ref proposal types are by default allowed. Custom MlsRules may disallow
+/// specific custom by-ref proposals.
+fn ensure_no_proposal_by_ref(proposals: &ProposalBundle) -> Result<(), MlsError> {
+    proposals
+        .iter_proposals()
+        .all(|p| !ProposalType::DEFAULT.contains(&p.proposal.proposal_type()) || p.is_by_value())
+        .then_some(())
+        .ok_or(MlsError::OnlyMembersCanCommitProposalsByRef)
+}
+
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+async fn insert_external_leaf<I: IdentityProvider>(
+    tree: &mut TreeKemPublic,
+    leaf_node: LeafNode,
+    identity_provider: &I,
+    extensions: &ExtensionList,
+) -> Result<LeafIndex, MlsError> {
+    tree.add_leaf(leaf_node, identity_provider, extensions, None)
+        .await
+}
diff --git a/src/group/proposal_filter/filtering_lite.rs b/src/group/proposal_filter/filtering_lite.rs
new file mode 100644
index 0000000..09ca389
--- /dev/null
+++ b/src/group/proposal_filter/filtering_lite.rs
@@ -0,0 +1,225 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// Copyright by contributors to this project.
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+
+use crate::{
+    client::MlsError,
+    group::proposal_filter::ProposalBundle,
+    iter::wrap_iter,
+    protocol_version::ProtocolVersion,
+    time::MlsTime,
+    tree_kem::{leaf_node_validator::LeafNodeValidator, node::LeafIndex},
+    CipherSuiteProvider, ExtensionList,
+};
+
+use super::filtering_common::{filter_out_invalid_psks, ApplyProposalsOutput, ProposalApplier};
+
+#[cfg(feature = "by_ref_proposal")]
+use {crate::extension::ExternalSendersExt, mls_rs_core::error::IntoAnyError};
+
+use mls_rs_core::{identity::IdentityProvider, psk::PreSharedKeyStorage};
+
+#[cfg(feature = "custom_proposal")]
+use itertools::Itertools;
+
+#[cfg(all(not(mls_build_async), feature = "rayon"))]
+use rayon::prelude::*;
+
+#[cfg(mls_build_async)]
+use futures::{StreamExt, TryStreamExt};
+
+#[cfg(feature = "custom_proposal")]
+use crate::tree_kem::TreeKemPublic;
+
+#[cfg(feature = "psk")]
+use crate::group::{
+    proposal::PreSharedKeyProposal, JustPreSharedKeyID, ResumptionPSKUsage, ResumptionPsk,
+};
+
+#[cfg(all(feature = "std", feature = "psk"))]
+use std::collections::HashSet;
+
+impl<'a, C, P, CSP> ProposalApplier<'a, C, P, CSP>
+where
+    C: IdentityProvider,
+    P: PreSharedKeyStorage,
+    CSP: CipherSuiteProvider,
+{
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(super) async fn apply_proposals_from_member(
+        &self,
+        commit_sender: LeafIndex,
+        proposals: &ProposalBundle,
+        commit_time: Option<MlsTime>,
+    ) -> Result<ApplyProposalsOutput, MlsError> {
+        filter_out_removal_of_committer(commit_sender, proposals)?;
+        filter_out_invalid_psks(self.cipher_suite_provider, proposals, self.psk_storage).await?;
+
+        #[cfg(feature = "by_ref_proposal")]
+        filter_out_invalid_group_extensions(proposals, self.identity_provider, commit_time).await?;
+
+        filter_out_extra_group_context_extensions(proposals)?;
+        filter_out_invalid_reinit(proposals, self.protocol_version)?;
+        filter_out_reinit_if_other_proposals(proposals)?;
+
+        self.apply_proposal_changes(proposals, commit_time).await
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(super) async fn apply_proposal_changes(
+        &self,
+        proposals: &ProposalBundle,
+        commit_time: Option<MlsTime>,
+    ) -> Result<ApplyProposalsOutput, MlsError> {
+        match proposals.group_context_extensions_proposal().cloned() {
+            Some(p) => {
+                self.apply_proposals_with_new_capabilities(proposals, p, commit_time)
+                    .await
+            }
+            None => {
+                self.apply_tree_changes(proposals, self.original_group_extensions, commit_time)
+                    .await
+            }
+        }
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(super) async fn apply_tree_changes(
+        &self,
+        proposals: &ProposalBundle,
+        group_extensions_in_use: &ExtensionList,
+        commit_time: Option<MlsTime>,
+    ) -> Result<ApplyProposalsOutput, MlsError> {
+        self.validate_new_nodes(proposals, group_extensions_in_use, commit_time)
+            .await?;
+
+        let mut new_tree = self.original_tree.clone();
+
+        let added = new_tree
+            .batch_edit_lite(
+                proposals,
+                group_extensions_in_use,
+                self.identity_provider,
+                self.cipher_suite_provider,
+            )
+            .await?;
+
+        let new_context_extensions = proposals
+            .group_context_extensions
+            .first()
+            .map(|gce| gce.proposal.clone());
+
+        Ok(ApplyProposalsOutput {
+            new_tree,
+            indexes_of_added_kpkgs: added,
+            external_init_index: None,
+            new_context_extensions,
+        })
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn validate_new_nodes(
+        &self,
+        proposals: &ProposalBundle,
+        group_extensions_in_use: &ExtensionList,
+        commit_time: Option<MlsTime>,
+    ) -> Result<(), MlsError> {
+        let leaf_node_validator = &LeafNodeValidator::new(
+            self.cipher_suite_provider,
+            self.identity_provider,
+            Some(group_extensions_in_use),
+        );
+
+        let adds = wrap_iter(proposals.add_proposals());
+
+        #[cfg(mls_build_async)]
+        let adds = adds.map(Ok);
+
+        { adds }
+            .try_for_each(|p| {
+                self.validate_new_node(leaf_node_validator, &p.proposal.key_package, commit_time)
+            })
+            .await
+    }
+}
+
+fn filter_out_removal_of_committer(
+    commit_sender: LeafIndex,
+    proposals: &ProposalBundle,
+) -> Result<(), MlsError> {
+    for p in &proposals.removals {
+        (p.proposal.to_remove != commit_sender)
+            .then_some(())
+            .ok_or(MlsError::CommitterSelfRemoval)?;
+    }
+
+    Ok(())
+}
+
+#[cfg(feature = "by_ref_proposal")]
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+async fn filter_out_invalid_group_extensions<C>(
+    proposals: &ProposalBundle,
+    identity_provider: &C,
+    commit_time: Option<MlsTime>,
+) -> Result<(), MlsError>
+where
+    C: IdentityProvider,
+{
+    if let Some(p) = proposals.group_context_extensions.first() {
+        if let Some(ext) = p.proposal.get_as::<ExternalSendersExt>()? {
+            ext.verify_all(identity_provider, commit_time, p.proposal())
+                .await
+                .map_err(|e| MlsError::IdentityProviderError(e.into_any_error()))?;
+        }
+    }
+
+    Ok(())
+}
+
+fn filter_out_extra_group_context_extensions(proposals: &ProposalBundle) -> Result<(), MlsError> {
+    (proposals.group_context_extensions.len() < 2)
+        .then_some(())
+        .ok_or(MlsError::MoreThanOneGroupContextExtensionsProposal)
+}
+
+fn filter_out_invalid_reinit(
+    proposals: &ProposalBundle,
+    protocol_version: ProtocolVersion,
+) -> Result<(), MlsError> {
+    if let Some(p) = proposals.reinitializations.first() {
+        (p.proposal.version >= protocol_version)
+            .then_some(())
+            .ok_or(MlsError::InvalidProtocolVersionInReInit)?;
+    }
+
+    Ok(())
+}
+
+fn filter_out_reinit_if_other_proposals(proposals: &ProposalBundle) -> Result<(), MlsError> {
+    (proposals.reinitializations.is_empty() || proposals.length() == 1)
+        .then_some(())
+        .ok_or(MlsError::OtherProposalWithReInit)
+}
+
+#[cfg(feature = "custom_proposal")]
+pub(super) fn filter_out_unsupported_custom_proposals(
+    proposals: &ProposalBundle,
+    tree: &TreeKemPublic,
+) -> Result<(), MlsError> {
+    let supported_types = proposals
+        .custom_proposal_types()
+        .filter(|t| tree.can_support_proposal(*t))
+        .collect_vec();
+
+    for p in &proposals.custom_proposals {
+        let proposal_type = p.proposal.proposal_type();
+
+        supported_types
+            .contains(&proposal_type)
+            .then_some(())
+            .ok_or(MlsError::UnsupportedCustomProposal(proposal_type))?;
+    }
+
+    Ok(())
+}
diff --git a/src/group/proposal_ref.rs b/src/group/proposal_ref.rs
new file mode 100644
index 0000000..c97c9a1
--- /dev/null
+++ b/src/group/proposal_ref.rs
@@ -0,0 +1,226 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// Copyright by contributors to this project.
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+
+use core::ops::Deref;
+
+use super::*;
+use crate::hash_reference::HashReference;
+
+#[cfg_attr(
+    all(feature = "ffi", not(test)),
+    safer_ffi_gen::ffi_type(clone, opaque)
+)]
+#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, MlsSize, MlsEncode, MlsDecode)]
+#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+/// Unique identifier for a proposal message.
+pub struct ProposalRef(HashReference);
+
+impl Deref for ProposalRef {
+    type Target = [u8];
+
+    fn deref(&self) -> &Self::Target {
+        &self.0
+    }
+}
+
+#[cfg_attr(all(feature = "ffi", not(test)), ::safer_ffi_gen::safer_ffi_gen)]
+impl ProposalRef {
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(crate) async fn from_content<CS: CipherSuiteProvider>(
+        cipher_suite_provider: &CS,
+        content: &AuthenticatedContent,
+    ) -> Result<Self, MlsError> {
+        let bytes = &content.mls_encode_to_vec()?;
+
+        Ok(ProposalRef(
+            HashReference::compute(bytes, b"MLS 1.0 Proposal Reference", cipher_suite_provider)
+                .await?,
+        ))
+    }
+
+    pub fn as_slice(&self) -> &[u8] {
+        &self.0
+    }
+}
+
+#[cfg(test)]
+pub(crate) mod test_utils {
+    use super::*;
+    use crate::group::test_utils::{random_bytes, TEST_GROUP};
+    use alloc::boxed::Box;
+
+    impl ProposalRef {
+        pub fn new_fake(bytes: Vec<u8>) -> Self {
+            Self(bytes.into())
+        }
+    }
+
+    pub fn auth_content_from_proposal<S>(proposal: Proposal, sender: S) -> AuthenticatedContent
+    where
+        S: Into<Sender>,
+    {
+        AuthenticatedContent {
+            wire_format: WireFormat::PublicMessage,
+            content: FramedContent {
+                group_id: TEST_GROUP.to_vec(),
+                epoch: 0,
+                sender: sender.into(),
+                authenticated_data: vec![],
+                content: Content::Proposal(Box::new(proposal)),
+            },
+            auth: FramedContentAuthData {
+                signature: MessageSignature::from(random_bytes(128)),
+                confirmation_tag: None,
+            },
+        }
+    }
+}
+
+#[cfg(test)]
+mod test {
+    use super::test_utils::auth_content_from_proposal;
+    use super::*;
+    use crate::{
+        crypto::test_utils::{test_cipher_suite_provider, try_test_cipher_suite_provider},
+        key_package::test_utils::test_key_package,
+        tree_kem::leaf_node::test_utils::get_basic_test_node,
+    };
+    use alloc::boxed::Box;
+
+    use crate::extension::RequiredCapabilitiesExt;
+
+    #[cfg_attr(coverage_nightly, coverage(off))]
+    fn get_test_extension_list() -> ExtensionList {
+        let test_extension = RequiredCapabilitiesExt {
+            extensions: vec![42.into()],
+            proposals: Default::default(),
+            credentials: vec![],
+        };
+
+        let mut extension_list = ExtensionList::new();
+        extension_list.set_from(test_extension).unwrap();
+
+        extension_list
+    }
+
+    #[derive(serde::Serialize, serde::Deserialize)]
+    struct TestCase {
+        cipher_suite: u16,
+        #[serde(with = "hex::serde")]
+        input: Vec<u8>,
+        #[serde(with = "hex::serde")]
+        output: Vec<u8>,
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    #[cfg_attr(coverage_nightly, coverage(off))]
+    async fn generate_proposal_test_cases() -> Vec<TestCase> {
+        let mut test_cases = Vec::new();
+
+        for (protocol_version, cipher_suite) in
+            ProtocolVersion::all().flat_map(|p| CipherSuite::all().map(move |cs| (p, cs)))
+        {
+            let sender = LeafIndex(0);
+
+            let add = auth_content_from_proposal(
+                Proposal::Add(Box::new(AddProposal {
+                    key_package: test_key_package(protocol_version, cipher_suite, "alice").await,
+                })),
+                sender,
+            );
+
+            let update = auth_content_from_proposal(
+                Proposal::Update(UpdateProposal {
+                    leaf_node: get_basic_test_node(cipher_suite, "foo").await,
+                }),
+                sender,
+            );
+
+            let remove = auth_content_from_proposal(
+                Proposal::Remove(RemoveProposal {
+                    to_remove: LeafIndex(1),
+                }),
+                sender,
+            );
+
+            let group_context_ext = auth_content_from_proposal(
+                Proposal::GroupContextExtensions(get_test_extension_list()),
+                sender,
+            );
+
+            let cipher_suite_provider = test_cipher_suite_provider(cipher_suite);
+
+            test_cases.push(TestCase {
+                cipher_suite: cipher_suite.into(),
+                input: add.mls_encode_to_vec().unwrap(),
+                output: ProposalRef::from_content(&cipher_suite_provider, &add)
+                    .await
+                    .unwrap()
+                    .to_vec(),
+            });
+
+            test_cases.push(TestCase {
+                cipher_suite: cipher_suite.into(),
+                input: update.mls_encode_to_vec().unwrap(),
+                output: ProposalRef::from_content(&cipher_suite_provider, &update)
+                    .await
+                    .unwrap()
+                    .to_vec(),
+            });
+
+            test_cases.push(TestCase {
+                cipher_suite: cipher_suite.into(),
+                input: remove.mls_encode_to_vec().unwrap(),
+                output: ProposalRef::from_content(&cipher_suite_provider, &remove)
+                    .await
+                    .unwrap()
+                    .to_vec(),
+            });
+
+            test_cases.push(TestCase {
+                cipher_suite: cipher_suite.into(),
+                input: group_context_ext.mls_encode_to_vec().unwrap(),
+                output: ProposalRef::from_content(&cipher_suite_provider, &group_context_ext)
+                    .await
+                    .unwrap()
+                    .to_vec(),
+            });
+        }
+
+        test_cases
+    }
+
+    #[cfg(mls_build_async)]
+    async fn load_test_cases() -> Vec<TestCase> {
+        load_test_case_json!(proposal_ref, generate_proposal_test_cases().await)
+    }
+
+    #[cfg(not(mls_build_async))]
+    fn load_test_cases() -> Vec<TestCase> {
+        load_test_case_json!(proposal_ref, generate_proposal_test_cases())
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_proposal_ref() {
+        let test_cases = load_test_cases().await;
+
+        for one_case in test_cases {
+            let Some(cs_provider) = try_test_cipher_suite_provider(one_case.cipher_suite) else {
+                continue;
+            };
+
+            let proposal_content =
+                AuthenticatedContent::mls_decode(&mut one_case.input.as_slice()).unwrap();
+
+            let proposal_ref = ProposalRef::from_content(&cs_provider, &proposal_content)
+                .await
+                .unwrap();
+
+            let expected_out = ProposalRef(HashReference::from(one_case.output));
+
+            assert_eq!(expected_out, proposal_ref);
+        }
+    }
+}
diff --git a/src/group/resumption.rs b/src/group/resumption.rs
new file mode 100644
index 0000000..3478ef3
--- /dev/null
+++ b/src/group/resumption.rs
@@ -0,0 +1,299 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// Copyright by contributors to this project.
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+
+use alloc::vec::Vec;
+
+use mls_rs_core::{
+    crypto::{CipherSuite, SignatureSecretKey},
+    extension::ExtensionList,
+    identity::SigningIdentity,
+    protocol_version::ProtocolVersion,
+};
+
+use crate::{client::MlsError, Client, Group, MlsMessage};
+
+use super::{
+    proposal::ReInitProposal, ClientConfig, ExportedTree, JustPreSharedKeyID, MessageProcessor,
+    NewMemberInfo, PreSharedKeyID, PskGroupId, PskSecretInput, ResumptionPSKUsage, ResumptionPsk,
+};
+
+struct ResumptionGroupParameters<'a> {
+    group_id: &'a [u8],
+    cipher_suite: CipherSuite,
+    version: ProtocolVersion,
+    extensions: &'a ExtensionList,
+}
+
+pub struct ReinitClient<C: ClientConfig + Clone> {
+    client: Client<C>,
+    reinit: ReInitProposal,
+    psk_input: PskSecretInput,
+}
+
+impl<C> Group<C>
+where
+    C: ClientConfig + Clone,
+{
+    /// Create a sub-group from a subset of the current group members.
+    ///
+    /// Membership within the resulting sub-group is indicated by providing a
+    /// key package that produces the same
+    /// [identity](crate::IdentityProvider::identity) value
+    /// as an existing group member. The identity value of each key package
+    /// is determined using the
+    /// [`IdentityProvider`](crate::IdentityProvider)
+    /// that is currently in use by this group instance.
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn branch(
+        &self,
+        sub_group_id: Vec<u8>,
+        new_key_packages: Vec<MlsMessage>,
+    ) -> Result<(Group<C>, Vec<MlsMessage>), MlsError> {
+        let new_group_params = ResumptionGroupParameters {
+            group_id: &sub_group_id,
+            cipher_suite: self.cipher_suite(),
+            version: self.protocol_version(),
+            extensions: &self.group_state().context.extensions,
+        };
+
+        resumption_create_group(
+            self.config.clone(),
+            new_key_packages,
+            &new_group_params,
+            // TODO investigate if it's worth updating your own signing identity here
+            self.current_member_signing_identity()?.clone(),
+            self.signer.clone(),
+            #[cfg(any(feature = "private_message", feature = "psk"))]
+            self.resumption_psk_input(ResumptionPSKUsage::Branch)?,
+        )
+        .await
+    }
+
+    /// Join a subgroup that was created by [`Group::branch`].
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn join_subgroup(
+        &self,
+        welcome: &MlsMessage,
+        tree_data: Option<ExportedTree<'_>>,
+    ) -> Result<(Group<C>, NewMemberInfo), MlsError> {
+        let expected_new_group_prams = ResumptionGroupParameters {
+            group_id: &[],
+            cipher_suite: self.cipher_suite(),
+            version: self.protocol_version(),
+            extensions: &self.group_state().context.extensions,
+        };
+
+        resumption_join_group(
+            self.config.clone(),
+            self.signer.clone(),
+            welcome,
+            tree_data,
+            expected_new_group_prams,
+            false,
+            self.resumption_psk_input(ResumptionPSKUsage::Branch)?,
+        )
+        .await
+    }
+
+    /// Generate a [`ReinitClient`] that can be used to create or join a new group
+    /// that is based on properties defined by a [`ReInitProposal`]
+    /// committed in a previously accepted commit. This is the only action available
+    /// after accepting such a commit. The old group can no longer be used according to the RFC.
+    ///
+    /// If the [`ReInitProposal`] changes the ciphersuite, then `new_signer`
+    /// and `new_signer_identity` must be set and match the new ciphersuite, as indicated by
+    /// [`pending_reinit_ciphersuite`](crate::group::StateUpdate::pending_reinit_ciphersuite)
+    /// of the [`StateUpdate`](crate::group::StateUpdate) outputted after processing the
+    /// commit to the reinit proposal. The value of [identity](crate::IdentityProvider::identity)
+    /// must be the same for `new_signing_identity` and the current identity in use by this
+    /// group instance.
+    pub fn get_reinit_client(
+        self,
+        new_signer: Option<SignatureSecretKey>,
+        new_signing_identity: Option<SigningIdentity>,
+    ) -> Result<ReinitClient<C>, MlsError> {
+        let psk_input = self.resumption_psk_input(ResumptionPSKUsage::Reinit)?;
+
+        let new_signing_identity = new_signing_identity
+            .map(Ok)
+            .unwrap_or_else(|| self.current_member_signing_identity().cloned())?;
+
+        let reinit = self
+            .state
+            .pending_reinit
+            .ok_or(MlsError::PendingReInitNotFound)?;
+
+        let new_signer = match new_signer {
+            Some(signer) => signer,
+            None => self.signer,
+        };
+
+        let client = Client::new(
+            self.config,
+            Some(new_signer),
+            Some((new_signing_identity, reinit.new_cipher_suite())),
+            reinit.new_version(),
+        );
+
+        Ok(ReinitClient {
+            client,
+            reinit,
+            psk_input,
+        })
+    }
+
+    fn resumption_psk_input(&self, usage: ResumptionPSKUsage) -> Result<PskSecretInput, MlsError> {
+        let psk = self.epoch_secrets.resumption_secret.clone();
+
+        let id = JustPreSharedKeyID::Resumption(ResumptionPsk {
+            usage,
+            psk_group_id: PskGroupId(self.group_id().to_vec()),
+            psk_epoch: self.current_epoch(),
+        });
+
+        let id = PreSharedKeyID::new(id, self.cipher_suite_provider())?;
+        Ok(PskSecretInput { id, psk })
+    }
+}
+
+/// A [`Client`] that can be used to create or join a new group
+/// that is based on properties defined by a [`ReInitProposal`]
+/// committed in a previously accepted commit.
+impl<C: ClientConfig + Clone> ReinitClient<C> {
+    /// Generate a key package for the new group. The key package can
+    /// be used in [`ReinitClient::commit`].
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn generate_key_package(&self) -> Result<MlsMessage, MlsError> {
+        self.client.generate_key_package_message().await
+    }
+
+    /// Create the new group using new key packages of all group members, possibly
+    /// generated by [`ReinitClient::generate_key_package`].
+    ///
+    /// # Warning
+    ///
+    /// This function will fail if the number of members in the reinitialized
+    /// group is not the same as the prior group roster.
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn commit(
+        self,
+        new_key_packages: Vec<MlsMessage>,
+    ) -> Result<(Group<C>, Vec<MlsMessage>), MlsError> {
+        let new_group_params = ResumptionGroupParameters {
+            group_id: self.reinit.group_id(),
+            cipher_suite: self.reinit.new_cipher_suite(),
+            version: self.reinit.new_version(),
+            extensions: self.reinit.new_group_context_extensions(),
+        };
+
+        resumption_create_group(
+            self.client.config.clone(),
+            new_key_packages,
+            &new_group_params,
+            // These private fields are created with `Some(x)` by `get_reinit_client`
+            self.client.signing_identity.unwrap().0,
+            self.client.signer.unwrap(),
+            #[cfg(any(feature = "private_message", feature = "psk"))]
+            self.psk_input,
+        )
+        .await
+    }
+
+    /// Join a reinitialized group that was created by [`ReinitClient::commit`].
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn join(
+        self,
+        welcome: &MlsMessage,
+        tree_data: Option<ExportedTree<'_>>,
+    ) -> Result<(Group<C>, NewMemberInfo), MlsError> {
+        let reinit = self.reinit;
+
+        let expected_group_params = ResumptionGroupParameters {
+            group_id: reinit.group_id(),
+            cipher_suite: reinit.new_cipher_suite(),
+            version: reinit.new_version(),
+            extensions: reinit.new_group_context_extensions(),
+        };
+
+        resumption_join_group(
+            self.client.config,
+            // This private field is created with `Some(x)` by `get_reinit_client`
+            self.client.signer.unwrap(),
+            welcome,
+            tree_data,
+            expected_group_params,
+            true,
+            self.psk_input,
+        )
+        .await
+    }
+}
+
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+async fn resumption_create_group<C: ClientConfig + Clone>(
+    config: C,
+    new_key_packages: Vec<MlsMessage>,
+    new_group_params: &ResumptionGroupParameters<'_>,
+    signing_identity: SigningIdentity,
+    signer: SignatureSecretKey,
+    psk_input: PskSecretInput,
+) -> Result<(Group<C>, Vec<MlsMessage>), MlsError> {
+    // Create a new group with new parameters
+    let mut group = Group::new(
+        config,
+        Some(new_group_params.group_id.to_vec()),
+        new_group_params.cipher_suite,
+        new_group_params.version,
+        signing_identity,
+        new_group_params.extensions.clone(),
+        signer,
+    )
+    .await?;
+
+    // Install the resumption psk in the new group
+    group.previous_psk = Some(psk_input);
+
+    // Create a commit that adds new key packages and uses the resumption PSK
+    let mut commit = group.commit_builder();
+
+    for kp in new_key_packages.into_iter() {
+        commit = commit.add_member(kp)?;
+    }
+
+    let commit = commit.build().await?;
+    group.apply_pending_commit().await?;
+
+    // Uninstall the resumption psk on success (in case of failure, the new group is discarded anyway)
+    group.previous_psk = None;
+
+    Ok((group, commit.welcome_messages))
+}
+
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+async fn resumption_join_group<C: ClientConfig + Clone>(
+    config: C,
+    signer: SignatureSecretKey,
+    welcome: &MlsMessage,
+    tree_data: Option<ExportedTree<'_>>,
+    expected_new_group_params: ResumptionGroupParameters<'_>,
+    verify_group_id: bool,
+    psk_input: PskSecretInput,
+) -> Result<(Group<C>, NewMemberInfo), MlsError> {
+    let psk_input = Some(psk_input);
+
+    let (group, new_member_info) =
+        Group::<C>::from_welcome_message(welcome, tree_data, config, signer, psk_input).await?;
+
+    if group.protocol_version() != expected_new_group_params.version {
+        Err(MlsError::ProtocolVersionMismatch)
+    } else if group.cipher_suite() != expected_new_group_params.cipher_suite {
+        Err(MlsError::CipherSuiteMismatch)
+    } else if verify_group_id && group.group_id() != expected_new_group_params.group_id {
+        Err(MlsError::GroupIdMismatch)
+    } else if &group.group_state().context.extensions != expected_new_group_params.extensions {
+        Err(MlsError::ReInitExtensionsMismatch)
+    } else {
+        Ok((group, new_member_info))
+    }
+}
diff --git a/src/group/roster.rs b/src/group/roster.rs
new file mode 100644
index 0000000..dd0a9f0
--- /dev/null
+++ b/src/group/roster.rs
@@ -0,0 +1,91 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// Copyright by contributors to this project.
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+
+use super::*;
+
+pub use mls_rs_core::group::Member;
+
+#[cfg(feature = "state_update")]
+pub(crate) fn member_from_key_package(key_package: &KeyPackage, index: LeafIndex) -> Member {
+    member_from_leaf_node(&key_package.leaf_node, index)
+}
+
+pub(crate) fn member_from_leaf_node(leaf_node: &LeafNode, leaf_index: LeafIndex) -> Member {
+    Member::new(
+        *leaf_index,
+        leaf_node.signing_identity.clone(),
+        leaf_node.ungreased_capabilities(),
+        leaf_node.ungreased_extensions(),
+    )
+}
+
+#[cfg_attr(
+    all(feature = "ffi", not(test)),
+    safer_ffi_gen::ffi_type(clone, opaque)
+)]
+#[derive(Clone, Debug)]
+pub struct Roster<'a> {
+    pub(crate) public_tree: &'a TreeKemPublic,
+}
+
+#[cfg_attr(all(feature = "ffi", not(test)), safer_ffi_gen::safer_ffi_gen)]
+impl<'a> Roster<'a> {
+    /// Iterator over the current roster that lazily copies data out of the
+    /// internal group state.
+    ///
+    /// # Warning
+    ///
+    /// The indexes within this iterator do not correlate with indexes of users
+    /// within [`ReceivedMessage`] content descriptions due to the layout of
+    /// member information within a MLS group state.
+    #[cfg_attr(all(feature = "ffi", not(test)), safer_ffi_gen::safer_ffi_gen_ignore)]
+    pub fn members_iter(&self) -> impl Iterator<Item = Member> + 'a {
+        self.public_tree
+            .non_empty_leaves()
+            .map(|(index, node)| member_from_leaf_node(node, index))
+    }
+
+    /// The current set of group members. This function makes a clone of
+    /// member information from the internal group state.
+    ///
+    /// # Warning
+    ///
+    /// The indexes within this roster do not correlate with indexes of users
+    /// within [`ReceivedMessage`] content descriptions due to the layout of
+    /// member information within a MLS group state.
+    pub fn members(&self) -> Vec<Member> {
+        self.members_iter().collect()
+    }
+
+    /// Retrieve the member with given `index` within the group in time `O(1)`.
+    /// This index does correlate with indexes of users within [`ReceivedMessage`]
+    /// content descriptions.
+    pub fn member_with_index(&self, index: u32) -> Result<Member, MlsError> {
+        let index = LeafIndex(index);
+
+        self.public_tree
+            .get_leaf_node(index)
+            .map(|l| member_from_leaf_node(l, index))
+    }
+
+    /// Iterator over member's signing identities.
+    ///
+    /// # Warning
+    ///
+    /// The indexes within this iterator do not correlate with indexes of users
+    /// within [`ReceivedMessage`] content descriptions due to the layout of
+    /// member information within a MLS group state.
+    #[cfg_attr(all(feature = "ffi", not(test)), safer_ffi_gen::safer_ffi_gen_ignore)]
+    pub fn member_identities_iter(&self) -> impl Iterator<Item = &SigningIdentity> + '_ {
+        self.public_tree
+            .non_empty_leaves()
+            .map(|(_, node)| &node.signing_identity)
+    }
+}
+
+impl TreeKemPublic {
+    pub(crate) fn roster(&self) -> Roster {
+        Roster { public_tree: self }
+    }
+}
diff --git a/src/group/secret_tree.rs b/src/group/secret_tree.rs
new file mode 100644
index 0000000..df0c30f
--- /dev/null
+++ b/src/group/secret_tree.rs
@@ -0,0 +1,1115 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// Copyright by contributors to this project.
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+
+use alloc::vec::Vec;
+use core::{
+    fmt::{self, Debug},
+    ops::{Deref, DerefMut},
+};
+
+use zeroize::Zeroizing;
+
+use crate::{client::MlsError, tree_kem::math::TreeIndex, CipherSuiteProvider};
+
+use mls_rs_codec::{MlsDecode, MlsEncode, MlsSize};
+use mls_rs_core::error::IntoAnyError;
+
+#[cfg(feature = "std")]
+use std::collections::HashMap;
+
+#[cfg(not(feature = "std"))]
+use alloc::collections::BTreeMap;
+
+use super::key_schedule::kdf_expand_with_label;
+
+pub(crate) const MAX_RATCHET_BACK_HISTORY: u32 = 1024;
+
+#[derive(Clone, Debug, PartialEq, MlsSize, MlsEncode, MlsDecode)]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[repr(u8)]
+enum SecretTreeNode {
+    Secret(TreeSecret) = 0u8,
+    Ratchet(SecretRatchets) = 1u8,
+}
+
+impl SecretTreeNode {
+    fn into_secret(self) -> Option<TreeSecret> {
+        if let SecretTreeNode::Secret(secret) = self {
+            Some(secret)
+        } else {
+            None
+        }
+    }
+}
+
+#[derive(Clone, PartialEq, MlsEncode, MlsDecode, MlsSize)]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+struct TreeSecret(
+    #[mls_codec(with = "mls_rs_codec::byte_vec")]
+    #[cfg_attr(feature = "serde", serde(with = "mls_rs_core::zeroizing_serde"))]
+    Zeroizing<Vec<u8>>,
+);
+
+impl Debug for TreeSecret {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        mls_rs_core::debug::pretty_bytes(&self.0)
+            .named("TreeSecret")
+            .fmt(f)
+    }
+}
+
+impl Deref for TreeSecret {
+    type Target = Vec<u8>;
+
+    fn deref(&self) -> &Self::Target {
+        &self.0
+    }
+}
+
+impl DerefMut for TreeSecret {
+    fn deref_mut(&mut self) -> &mut Self::Target {
+        &mut self.0
+    }
+}
+
+impl AsRef<[u8]> for TreeSecret {
+    fn as_ref(&self) -> &[u8] {
+        &self.0
+    }
+}
+
+impl From<Vec<u8>> for TreeSecret {
+    fn from(vec: Vec<u8>) -> Self {
+        TreeSecret(Zeroizing::new(vec))
+    }
+}
+
+impl From<Zeroizing<Vec<u8>>> for TreeSecret {
+    fn from(vec: Zeroizing<Vec<u8>>) -> Self {
+        TreeSecret(vec)
+    }
+}
+
+#[derive(Clone, Debug, PartialEq, MlsEncode, MlsDecode, MlsSize, Default)]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+struct TreeSecretsVec<T: TreeIndex> {
+    #[cfg(feature = "std")]
+    inner: HashMap<T, SecretTreeNode>,
+    #[cfg(not(feature = "std"))]
+    inner: Vec<(T, SecretTreeNode)>,
+}
+
+#[cfg(feature = "std")]
+impl<T: TreeIndex> TreeSecretsVec<T> {
+    fn set_node(&mut self, index: T, value: SecretTreeNode) {
+        self.inner.insert(index, value);
+    }
+
+    fn take_node(&mut self, index: &T) -> Option<SecretTreeNode> {
+        self.inner.remove(index)
+    }
+}
+
+#[cfg(not(feature = "std"))]
+impl<T: TreeIndex> TreeSecretsVec<T> {
+    fn set_node(&mut self, index: T, value: SecretTreeNode) {
+        if let Some(i) = self.find_node(&index) {
+            self.inner[i] = (index, value)
+        } else {
+            self.inner.push((index, value))
+        }
+    }
+
+    fn take_node(&mut self, index: &T) -> Option<SecretTreeNode> {
+        self.find_node(index).map(|i| self.inner.remove(i).1)
+    }
+
+    fn find_node(&self, index: &T) -> Option<usize> {
+        use itertools::Itertools;
+
+        self.inner
+            .iter()
+            .find_position(|(i, _)| i == index)
+            .map(|(i, _)| i)
+    }
+}
+
+#[derive(Clone, Debug, PartialEq, MlsEncode, MlsDecode, MlsSize)]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+pub struct SecretTree<T: TreeIndex> {
+    known_secrets: TreeSecretsVec<T>,
+    leaf_count: T,
+}
+
+impl<T: TreeIndex> SecretTree<T> {
+    pub(crate) fn empty() -> SecretTree<T> {
+        SecretTree {
+            known_secrets: Default::default(),
+            leaf_count: T::zero(),
+        }
+    }
+}
+
+#[derive(Clone, Debug, PartialEq, MlsSize, MlsEncode, MlsDecode)]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+pub struct SecretRatchets {
+    pub application: SecretKeyRatchet,
+    pub handshake: SecretKeyRatchet,
+}
+
+impl SecretRatchets {
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn message_key_generation<P: CipherSuiteProvider>(
+        &mut self,
+        cipher_suite_provider: &P,
+        generation: u32,
+        key_type: KeyType,
+    ) -> Result<MessageKeyData, MlsError> {
+        match key_type {
+            KeyType::Handshake => {
+                self.handshake
+                    .get_message_key(cipher_suite_provider, generation)
+                    .await
+            }
+            KeyType::Application => {
+                self.application
+                    .get_message_key(cipher_suite_provider, generation)
+                    .await
+            }
+        }
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn next_message_key<P: CipherSuiteProvider>(
+        &mut self,
+        cipher_suite: &P,
+        key_type: KeyType,
+    ) -> Result<MessageKeyData, MlsError> {
+        match key_type {
+            KeyType::Handshake => self.handshake.next_message_key(cipher_suite).await,
+            KeyType::Application => self.application.next_message_key(cipher_suite).await,
+        }
+    }
+}
+
+impl<T: TreeIndex> SecretTree<T> {
+    pub fn new(leaf_count: T, encryption_secret: Zeroizing<Vec<u8>>) -> SecretTree<T> {
+        let mut known_secrets = TreeSecretsVec::default();
+
+        let root_secret = SecretTreeNode::Secret(TreeSecret::from(encryption_secret));
+        known_secrets.set_node(leaf_count.root(), root_secret);
+
+        Self {
+            known_secrets,
+            leaf_count,
+        }
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn consume_node<P: CipherSuiteProvider>(
+        &mut self,
+        cipher_suite_provider: &P,
+        index: &T,
+    ) -> Result<(), MlsError> {
+        let node = self.known_secrets.take_node(index);
+
+        if let Some(secret) = node.and_then(|n| n.into_secret()) {
+            let left_index = index.left().ok_or(MlsError::LeafNodeNoChildren)?;
+            let right_index = index.right().ok_or(MlsError::LeafNodeNoChildren)?;
+
+            let left_secret =
+                kdf_expand_with_label(cipher_suite_provider, &secret, b"tree", b"left", None)
+                    .await?;
+
+            let right_secret =
+                kdf_expand_with_label(cipher_suite_provider, &secret, b"tree", b"right", None)
+                    .await?;
+
+            self.known_secrets
+                .set_node(left_index, SecretTreeNode::Secret(left_secret.into()));
+
+            self.known_secrets
+                .set_node(right_index, SecretTreeNode::Secret(right_secret.into()));
+        }
+
+        Ok(())
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn take_leaf_ratchet<P: CipherSuiteProvider>(
+        &mut self,
+        cipher_suite: &P,
+        leaf_index: &T,
+    ) -> Result<SecretRatchets, MlsError> {
+        let node_index = leaf_index;
+
+        let node = match self.known_secrets.take_node(node_index) {
+            Some(node) => node,
+            None => {
+                // Start at the root node and work your way down consuming any intermediates needed
+                for i in node_index.direct_copath(&self.leaf_count).into_iter().rev() {
+                    self.consume_node(cipher_suite, &i.path).await?;
+                }
+
+                self.known_secrets
+                    .take_node(node_index)
+                    .ok_or(MlsError::InvalidLeafConsumption)?
+            }
+        };
+
+        Ok(match node {
+            SecretTreeNode::Ratchet(ratchet) => ratchet,
+            SecretTreeNode::Secret(secret) => SecretRatchets {
+                application: SecretKeyRatchet::new(cipher_suite, &secret, KeyType::Application)
+                    .await?,
+                handshake: SecretKeyRatchet::new(cipher_suite, &secret, KeyType::Handshake).await?,
+            },
+        })
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn next_message_key<P: CipherSuiteProvider>(
+        &mut self,
+        cipher_suite: &P,
+        leaf_index: T,
+        key_type: KeyType,
+    ) -> Result<MessageKeyData, MlsError> {
+        let mut ratchet = self.take_leaf_ratchet(cipher_suite, &leaf_index).await?;
+        let res = ratchet.next_message_key(cipher_suite, key_type).await?;
+
+        self.known_secrets
+            .set_node(leaf_index, SecretTreeNode::Ratchet(ratchet));
+
+        Ok(res)
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn message_key_generation<P: CipherSuiteProvider>(
+        &mut self,
+        cipher_suite: &P,
+        leaf_index: T,
+        key_type: KeyType,
+        generation: u32,
+    ) -> Result<MessageKeyData, MlsError> {
+        let mut ratchet = self.take_leaf_ratchet(cipher_suite, &leaf_index).await?;
+
+        let res = ratchet
+            .message_key_generation(cipher_suite, generation, key_type)
+            .await?;
+
+        self.known_secrets
+            .set_node(leaf_index, SecretTreeNode::Ratchet(ratchet));
+
+        Ok(res)
+    }
+}
+
+#[derive(Clone, Copy)]
+pub enum KeyType {
+    Handshake,
+    Application,
+}
+
+#[cfg_attr(
+    all(feature = "ffi", not(test)),
+    safer_ffi_gen::ffi_type(clone, opaque)
+)]
+#[derive(Clone, PartialEq, Eq, MlsEncode, MlsDecode, MlsSize)]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+/// AEAD key derived by the MLS secret tree.
+pub struct MessageKeyData {
+    #[mls_codec(with = "mls_rs_codec::byte_vec")]
+    #[cfg_attr(feature = "serde", serde(with = "mls_rs_core::zeroizing_serde"))]
+    pub(crate) nonce: Zeroizing<Vec<u8>>,
+    #[mls_codec(with = "mls_rs_codec::byte_vec")]
+    #[cfg_attr(feature = "serde", serde(with = "mls_rs_core::zeroizing_serde"))]
+    pub(crate) key: Zeroizing<Vec<u8>>,
+    pub(crate) generation: u32,
+}
+
+impl Debug for MessageKeyData {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.debug_struct("MessageKeyData")
+            .field("nonce", &mls_rs_core::debug::pretty_bytes(&self.nonce))
+            .field("key", &mls_rs_core::debug::pretty_bytes(&self.key))
+            .field("generation", &self.generation)
+            .finish()
+    }
+}
+
+#[cfg_attr(all(feature = "ffi", not(test)), safer_ffi_gen::safer_ffi_gen)]
+impl MessageKeyData {
+    /// AEAD nonce.
+    #[cfg_attr(not(feature = "secret_tree_access"), allow(dead_code))]
+    pub fn nonce(&self) -> &[u8] {
+        &self.nonce
+    }
+
+    /// AEAD key.
+    #[cfg_attr(not(feature = "secret_tree_access"), allow(dead_code))]
+    pub fn key(&self) -> &[u8] {
+        &self.key
+    }
+
+    /// Generation of this key within the key schedule.
+    #[cfg_attr(not(feature = "secret_tree_access"), allow(dead_code))]
+    pub fn generation(&self) -> u32 {
+        self.generation
+    }
+}
+
+#[derive(Debug, Clone, PartialEq)]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+pub struct SecretKeyRatchet {
+    secret: TreeSecret,
+    generation: u32,
+    #[cfg(all(feature = "out_of_order", feature = "std"))]
+    history: HashMap<u32, MessageKeyData>,
+    #[cfg(all(feature = "out_of_order", not(feature = "std")))]
+    history: BTreeMap<u32, MessageKeyData>,
+}
+
+impl MlsSize for SecretKeyRatchet {
+    fn mls_encoded_len(&self) -> usize {
+        let len = mls_rs_codec::byte_vec::mls_encoded_len(&self.secret)
+            + self.generation.mls_encoded_len();
+
+        #[cfg(feature = "out_of_order")]
+        return len + mls_rs_codec::iter::mls_encoded_len(self.history.values());
+        #[cfg(not(feature = "out_of_order"))]
+        return len;
+    }
+}
+
+#[cfg(feature = "out_of_order")]
+impl MlsEncode for SecretKeyRatchet {
+    fn mls_encode(&self, writer: &mut Vec<u8>) -> Result<(), mls_rs_codec::Error> {
+        mls_rs_codec::byte_vec::mls_encode(&self.secret, writer)?;
+        self.generation.mls_encode(writer)?;
+        mls_rs_codec::iter::mls_encode(self.history.values(), writer)
+    }
+}
+
+#[cfg(not(feature = "out_of_order"))]
+impl MlsEncode for SecretKeyRatchet {
+    fn mls_encode(&self, writer: &mut Vec<u8>) -> Result<(), mls_rs_codec::Error> {
+        mls_rs_codec::byte_vec::mls_encode(&self.secret, writer)?;
+        self.generation.mls_encode(writer)
+    }
+}
+
+impl MlsDecode for SecretKeyRatchet {
+    fn mls_decode(reader: &mut &[u8]) -> Result<Self, mls_rs_codec::Error> {
+        Ok(Self {
+            secret: mls_rs_codec::byte_vec::mls_decode(reader)?,
+            generation: u32::mls_decode(reader)?,
+            #[cfg(all(feature = "std", feature = "out_of_order"))]
+            history: mls_rs_codec::iter::mls_decode_collection(reader, |data| {
+                let mut items = HashMap::default();
+
+                while !data.is_empty() {
+                    let item = MessageKeyData::mls_decode(data)?;
+                    items.insert(item.generation, item);
+                }
+
+                Ok(items)
+            })?,
+            #[cfg(all(not(feature = "std"), feature = "out_of_order"))]
+            history: mls_rs_codec::iter::mls_decode_collection(reader, |data| {
+                let mut items = alloc::collections::BTreeMap::default();
+
+                while !data.is_empty() {
+                    let item = MessageKeyData::mls_decode(data)?;
+                    items.insert(item.generation, item);
+                }
+
+                Ok(items)
+            })?,
+        })
+    }
+}
+
+impl SecretKeyRatchet {
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn new<P: CipherSuiteProvider>(
+        cipher_suite_provider: &P,
+        secret: &[u8],
+        key_type: KeyType,
+    ) -> Result<Self, MlsError> {
+        let label = match key_type {
+            KeyType::Handshake => b"handshake".as_slice(),
+            KeyType::Application => b"application".as_slice(),
+        };
+
+        let secret = kdf_expand_with_label(cipher_suite_provider, secret, label, &[], None)
+            .await
+            .map_err(|e| MlsError::CryptoProviderError(e.into_any_error()))?;
+
+        Ok(Self {
+            secret: TreeSecret::from(secret),
+            generation: 0,
+            #[cfg(feature = "out_of_order")]
+            history: Default::default(),
+        })
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn get_message_key<P: CipherSuiteProvider>(
+        &mut self,
+        cipher_suite_provider: &P,
+        generation: u32,
+    ) -> Result<MessageKeyData, MlsError> {
+        #[cfg(feature = "out_of_order")]
+        if generation < self.generation {
+            return self
+                .history
+                .remove_entry(&generation)
+                .map(|(_, mk)| mk)
+                .ok_or(MlsError::KeyMissing(generation));
+        }
+
+        #[cfg(not(feature = "out_of_order"))]
+        if generation < self.generation {
+            return Err(MlsError::KeyMissing(generation));
+        }
+
+        let max_generation_allowed = self.generation + MAX_RATCHET_BACK_HISTORY;
+
+        if generation > max_generation_allowed {
+            return Err(MlsError::InvalidFutureGeneration(generation));
+        }
+
+        #[cfg(not(feature = "out_of_order"))]
+        while self.generation < generation {
+            self.next_message_key(cipher_suite_provider)?;
+        }
+
+        #[cfg(feature = "out_of_order")]
+        while self.generation < generation {
+            let key_data = self.next_message_key(cipher_suite_provider).await?;
+            self.history.insert(key_data.generation, key_data);
+        }
+
+        self.next_message_key(cipher_suite_provider).await
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn next_message_key<P: CipherSuiteProvider>(
+        &mut self,
+        cipher_suite_provider: &P,
+    ) -> Result<MessageKeyData, MlsError> {
+        let generation = self.generation;
+
+        let key = MessageKeyData {
+            nonce: self
+                .derive_secret(
+                    cipher_suite_provider,
+                    b"nonce",
+                    cipher_suite_provider.aead_nonce_size(),
+                )
+                .await?,
+            key: self
+                .derive_secret(
+                    cipher_suite_provider,
+                    b"key",
+                    cipher_suite_provider.aead_key_size(),
+                )
+                .await?,
+            generation,
+        };
+
+        self.secret = self
+            .derive_secret(
+                cipher_suite_provider,
+                b"secret",
+                cipher_suite_provider.kdf_extract_size(),
+            )
+            .await?
+            .into();
+
+        self.generation = generation + 1;
+
+        Ok(key)
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn derive_secret<P: CipherSuiteProvider>(
+        &self,
+        cipher_suite_provider: &P,
+        label: &[u8],
+        len: usize,
+    ) -> Result<Zeroizing<Vec<u8>>, MlsError> {
+        kdf_expand_with_label(
+            cipher_suite_provider,
+            self.secret.as_ref(),
+            label,
+            &self.generation.to_be_bytes(),
+            Some(len),
+        )
+        .await
+        .map_err(|e| MlsError::CryptoProviderError(e.into_any_error()))
+    }
+}
+
+#[cfg(test)]
+pub(crate) mod test_utils {
+    use alloc::{string::String, vec::Vec};
+    use mls_rs_core::crypto::CipherSuiteProvider;
+    use zeroize::Zeroizing;
+
+    use crate::{crypto::test_utils::try_test_cipher_suite_provider, tree_kem::math::TreeIndex};
+
+    use super::{KeyType, SecretKeyRatchet, SecretTree};
+
+    pub(crate) fn get_test_tree<T: TreeIndex>(secret: Vec<u8>, leaf_count: T) -> SecretTree<T> {
+        SecretTree::new(leaf_count, Zeroizing::new(secret))
+    }
+
+    impl SecretTree<u32> {
+        pub(crate) fn get_root_secret(&self) -> Vec<u8> {
+            self.known_secrets
+                .clone()
+                .take_node(&self.leaf_count.root())
+                .unwrap()
+                .into_secret()
+                .unwrap()
+                .to_vec()
+        }
+    }
+
+    #[derive(Debug, serde::Serialize, serde::Deserialize)]
+    pub struct RatchetInteropTestCase {
+        #[serde(with = "hex::serde")]
+        secret: Vec<u8>,
+        label: String,
+        generation: u32,
+        length: usize,
+        #[serde(with = "hex::serde")]
+        out: Vec<u8>,
+    }
+
+    #[derive(Debug, serde::Serialize, serde::Deserialize)]
+    pub struct InteropTestCase {
+        cipher_suite: u16,
+        derive_tree_secret: RatchetInteropTestCase,
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_basic_crypto_test_vectors() {
+        let test_cases: Vec<InteropTestCase> =
+            load_test_case_json!(basic_crypto, Vec::<InteropTestCase>::new());
+
+        for test_case in test_cases {
+            if let Some(cs) = try_test_cipher_suite_provider(test_case.cipher_suite) {
+                test_case.derive_tree_secret.verify(&cs).await
+            }
+        }
+    }
+
+    impl RatchetInteropTestCase {
+        #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+        pub async fn verify<P: CipherSuiteProvider>(&self, cs: &P) {
+            let mut ratchet = SecretKeyRatchet::new(cs, &self.secret, KeyType::Application)
+                .await
+                .unwrap();
+
+            ratchet.secret = self.secret.clone().into();
+            ratchet.generation = self.generation;
+
+            let computed = ratchet
+                .derive_secret(cs, self.label.as_bytes(), self.length)
+                .await
+                .unwrap();
+
+            assert_eq!(&computed.to_vec(), &self.out);
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use alloc::vec;
+
+    use crate::{
+        cipher_suite::CipherSuite,
+        client::test_utils::TEST_CIPHER_SUITE,
+        crypto::test_utils::{
+            test_cipher_suite_provider, try_test_cipher_suite_provider, TestCryptoProvider,
+        },
+        tree_kem::node::NodeIndex,
+    };
+
+    #[cfg(not(mls_build_async))]
+    use crate::group::test_utils::random_bytes;
+
+    use super::{test_utils::get_test_tree, *};
+
+    use assert_matches::assert_matches;
+
+    #[cfg(target_arch = "wasm32")]
+    use wasm_bindgen_test::wasm_bindgen_test as test;
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_secret_tree() {
+        test_secret_tree_custom(16u32, (0..16).map(|i| 2 * i).collect(), true).await;
+        test_secret_tree_custom(1u64 << 62, (1..62).map(|i| 1u64 << i).collect(), false).await;
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn test_secret_tree_custom<T: TreeIndex>(
+        leaf_count: T,
+        leaves_to_check: Vec<T>,
+        all_deleted: bool,
+    ) {
+        for cipher_suite in TestCryptoProvider::all_supported_cipher_suites() {
+            let cs_provider = test_cipher_suite_provider(cipher_suite);
+
+            let test_secret = vec![0u8; cs_provider.kdf_extract_size()];
+            let mut test_tree = get_test_tree(test_secret, leaf_count.clone());
+
+            let mut secrets = Vec::<SecretRatchets>::new();
+
+            for i in &leaves_to_check {
+                let secret = test_tree
+                    .take_leaf_ratchet(&test_cipher_suite_provider(cipher_suite), i)
+                    .await
+                    .unwrap();
+
+                secrets.push(secret);
+            }
+
+            // Verify the tree is now completely empty
+            assert!(!all_deleted || test_tree.known_secrets.inner.is_empty());
+
+            // Verify that all the secrets are unique
+            let count = secrets.len();
+            secrets.dedup();
+            assert_eq!(count, secrets.len());
+        }
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_secret_key_ratchet() {
+        for cipher_suite in TestCryptoProvider::all_supported_cipher_suites() {
+            let provider = test_cipher_suite_provider(cipher_suite);
+
+            let mut app_ratchet = SecretKeyRatchet::new(
+                &provider,
+                &vec![0u8; provider.kdf_extract_size()],
+                KeyType::Application,
+            )
+            .await
+            .unwrap();
+
+            let mut handshake_ratchet = SecretKeyRatchet::new(
+                &provider,
+                &vec![0u8; provider.kdf_extract_size()],
+                KeyType::Handshake,
+            )
+            .await
+            .unwrap();
+
+            let app_key_one = app_ratchet.next_message_key(&provider).await.unwrap();
+            let app_key_two = app_ratchet.next_message_key(&provider).await.unwrap();
+            let app_keys = vec![app_key_one, app_key_two];
+
+            let handshake_key_one = handshake_ratchet.next_message_key(&provider).await.unwrap();
+            let handshake_key_two = handshake_ratchet.next_message_key(&provider).await.unwrap();
+            let handshake_keys = vec![handshake_key_one, handshake_key_two];
+
+            // Verify that the keys have different outcomes due to their different labels
+            assert_ne!(app_keys, handshake_keys);
+
+            // Verify that the keys at each generation are different
+            assert_ne!(handshake_keys[0], handshake_keys[1]);
+        }
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_get_key() {
+        for cipher_suite in TestCryptoProvider::all_supported_cipher_suites() {
+            let provider = test_cipher_suite_provider(cipher_suite);
+
+            let mut ratchet = SecretKeyRatchet::new(
+                &test_cipher_suite_provider(cipher_suite),
+                &vec![0u8; provider.kdf_extract_size()],
+                KeyType::Application,
+            )
+            .await
+            .unwrap();
+
+            let mut ratchet_clone = ratchet.clone();
+
+            // This will generate keys 0 and 1 in ratchet_clone
+            let _ = ratchet_clone.next_message_key(&provider).await.unwrap();
+            let clone_2 = ratchet_clone.next_message_key(&provider).await.unwrap();
+
+            // Going back in time should result in an error
+            let res = ratchet_clone.get_message_key(&provider, 0).await;
+            assert!(res.is_err());
+
+            // Calling get key should be the same as calling next until hitting the desired generation
+            let second_key = ratchet
+                .get_message_key(&provider, ratchet_clone.generation - 1)
+                .await
+                .unwrap();
+
+            assert_eq!(clone_2, second_key)
+        }
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_secret_ratchet() {
+        for cipher_suite in TestCryptoProvider::all_supported_cipher_suites() {
+            let provider = test_cipher_suite_provider(cipher_suite);
+
+            let mut ratchet = SecretKeyRatchet::new(
+                &provider,
+                &vec![0u8; provider.kdf_extract_size()],
+                KeyType::Application,
+            )
+            .await
+            .unwrap();
+
+            let original_secret = ratchet.secret.clone();
+            let _ = ratchet.next_message_key(&provider).await.unwrap();
+            let new_secret = ratchet.secret;
+            assert_ne!(original_secret, new_secret)
+        }
+    }
+
+    #[cfg(feature = "out_of_order")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_out_of_order_keys() {
+        let cipher_suite = TEST_CIPHER_SUITE;
+        let provider = test_cipher_suite_provider(cipher_suite);
+
+        let mut ratchet = SecretKeyRatchet::new(&provider, &[0u8; 32], KeyType::Handshake)
+            .await
+            .unwrap();
+        let mut ratchet_clone = ratchet.clone();
+
+        // Ask for all the keys in order from the original ratchet
+        let mut ordered_keys = Vec::<MessageKeyData>::new();
+
+        for i in 0..=MAX_RATCHET_BACK_HISTORY {
+            ordered_keys.push(ratchet.get_message_key(&provider, i).await.unwrap());
+        }
+
+        // Ask for a key at index MAX_RATCHET_BACK_HISTORY in the clone
+        let last_key = ratchet_clone
+            .get_message_key(&provider, MAX_RATCHET_BACK_HISTORY)
+            .await
+            .unwrap();
+
+        assert_eq!(last_key, ordered_keys[ordered_keys.len() - 1]);
+
+        // Get all the other keys
+        let mut back_history_keys = Vec::<MessageKeyData>::new();
+
+        for i in 0..MAX_RATCHET_BACK_HISTORY - 1 {
+            back_history_keys.push(ratchet_clone.get_message_key(&provider, i).await.unwrap());
+        }
+
+        assert_eq!(
+            back_history_keys,
+            ordered_keys[..(MAX_RATCHET_BACK_HISTORY as usize) - 1]
+        );
+    }
+
+    #[cfg(not(feature = "out_of_order"))]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn out_of_order_keys_should_throw_error() {
+        let cipher_suite = TEST_CIPHER_SUITE;
+        let provider = test_cipher_suite_provider(cipher_suite);
+
+        let mut ratchet = SecretKeyRatchet::new(&provider, &[0u8; 32], KeyType::Handshake)
+            .await
+            .unwrap();
+
+        ratchet.get_message_key(&provider, 10).await.unwrap();
+        let res = ratchet.get_message_key(&provider, 9).await;
+        assert_matches!(res, Err(MlsError::KeyMissing(9)))
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_too_out_of_order() {
+        let cipher_suite = TEST_CIPHER_SUITE;
+        let provider = test_cipher_suite_provider(cipher_suite);
+
+        let mut ratchet = SecretKeyRatchet::new(&provider, &[0u8; 32], KeyType::Handshake)
+            .await
+            .unwrap();
+
+        let res = ratchet
+            .get_message_key(&provider, MAX_RATCHET_BACK_HISTORY + 1)
+            .await;
+
+        let invalid_generation = MAX_RATCHET_BACK_HISTORY + 1;
+
+        assert_matches!(
+            res,
+            Err(MlsError::InvalidFutureGeneration(invalid))
+            if invalid == invalid_generation
+        )
+    }
+
+    #[derive(Debug, PartialEq, serde::Serialize, serde::Deserialize)]
+    struct Ratchet {
+        application_keys: Vec<Vec<u8>>,
+        handshake_keys: Vec<Vec<u8>>,
+    }
+
+    #[derive(Debug, serde::Serialize, serde::Deserialize)]
+    struct TestCase {
+        cipher_suite: u16,
+        #[serde(with = "hex::serde")]
+        encryption_secret: Vec<u8>,
+        ratchets: Vec<Ratchet>,
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn get_ratchet_data(
+        secret_tree: &mut SecretTree<NodeIndex>,
+        cipher_suite: CipherSuite,
+    ) -> Vec<Ratchet> {
+        let provider = test_cipher_suite_provider(cipher_suite);
+        let mut ratchet_data = Vec::new();
+
+        for index in 0..16 {
+            let mut ratchets = secret_tree
+                .take_leaf_ratchet(&provider, &(index * 2))
+                .await
+                .unwrap();
+
+            let mut application_keys = Vec::new();
+
+            for _ in 0..20 {
+                let key = ratchets
+                    .handshake
+                    .next_message_key(&provider)
+                    .await
+                    .unwrap()
+                    .mls_encode_to_vec()
+                    .unwrap();
+
+                application_keys.push(key);
+            }
+
+            let mut handshake_keys = Vec::new();
+
+            for _ in 0..20 {
+                let key = ratchets
+                    .handshake
+                    .next_message_key(&provider)
+                    .await
+                    .unwrap()
+                    .mls_encode_to_vec()
+                    .unwrap();
+
+                handshake_keys.push(key);
+            }
+
+            ratchet_data.push(Ratchet {
+                application_keys,
+                handshake_keys,
+            });
+        }
+
+        ratchet_data
+    }
+
+    #[cfg(not(mls_build_async))]
+    #[cfg_attr(coverage_nightly, coverage(off))]
+    fn generate_test_vector() -> Vec<TestCase> {
+        CipherSuite::all()
+            .map(|cipher_suite| {
+                let provider = test_cipher_suite_provider(cipher_suite);
+                let encryption_secret = random_bytes(provider.kdf_extract_size());
+
+                let mut secret_tree =
+                    SecretTree::new(16, Zeroizing::new(encryption_secret.clone()));
+
+                TestCase {
+                    cipher_suite: cipher_suite.into(),
+                    encryption_secret,
+                    ratchets: get_ratchet_data(&mut secret_tree, cipher_suite),
+                }
+            })
+            .collect()
+    }
+
+    #[cfg(mls_build_async)]
+    fn generate_test_vector() -> Vec<TestCase> {
+        panic!("Tests cannot be generated in async mode");
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_secret_tree_test_vectors() {
+        let test_cases: Vec<TestCase> = load_test_case_json!(secret_tree, generate_test_vector());
+
+        for case in test_cases {
+            let Some(cs_provider) = try_test_cipher_suite_provider(case.cipher_suite) else {
+                continue;
+            };
+
+            let mut secret_tree = SecretTree::new(16, Zeroizing::new(case.encryption_secret));
+            let ratchet_data = get_ratchet_data(&mut secret_tree, cs_provider.cipher_suite()).await;
+
+            assert_eq!(ratchet_data, case.ratchets);
+        }
+    }
+}
+
+#[cfg(all(test, feature = "rfc_compliant", feature = "std"))]
+mod interop_tests {
+    #[cfg(not(mls_build_async))]
+    use mls_rs_core::crypto::{CipherSuite, CipherSuiteProvider};
+    use zeroize::Zeroizing;
+
+    use crate::{
+        crypto::test_utils::try_test_cipher_suite_provider,
+        group::{ciphertext_processor::InteropSenderData, secret_tree::KeyType},
+    };
+
+    use super::SecretTree;
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn interop_test_vector() {
+        // The test vector can be found here https://github.com/mlswg/mls-implementations/blob/main/test-vectors/secret-tree.json
+        let test_cases = load_interop_test_cases();
+
+        for case in test_cases {
+            let Some(cs) = try_test_cipher_suite_provider(case.cipher_suite) else {
+                continue;
+            };
+
+            case.sender_data.verify(&cs).await;
+
+            let mut tree = SecretTree::new(
+                case.leaves.len() as u32,
+                Zeroizing::new(case.encryption_secret),
+            );
+
+            for (index, leaves) in case.leaves.iter().enumerate() {
+                for leaf in leaves.iter() {
+                    let key = tree
+                        .message_key_generation(
+                            &cs,
+                            (index as u32) * 2,
+                            KeyType::Application,
+                            leaf.generation,
+                        )
+                        .await
+                        .unwrap();
+
+                    assert_eq!(key.key.to_vec(), leaf.application_key);
+                    assert_eq!(key.nonce.to_vec(), leaf.application_nonce);
+
+                    let key = tree
+                        .message_key_generation(
+                            &cs,
+                            (index as u32) * 2,
+                            KeyType::Handshake,
+                            leaf.generation,
+                        )
+                        .await
+                        .unwrap();
+
+                    assert_eq!(key.key.to_vec(), leaf.handshake_key);
+                    assert_eq!(key.nonce.to_vec(), leaf.handshake_nonce);
+                }
+            }
+        }
+    }
+
+    #[derive(Debug, serde::Serialize, serde::Deserialize)]
+    struct InteropTestCase {
+        cipher_suite: u16,
+        #[serde(with = "hex::serde")]
+        encryption_secret: Vec<u8>,
+        sender_data: InteropSenderData,
+        leaves: Vec<Vec<InteropLeaf>>,
+    }
+
+    #[derive(Debug, serde::Serialize, serde::Deserialize)]
+    struct InteropLeaf {
+        generation: u32,
+        #[serde(with = "hex::serde")]
+        application_key: Vec<u8>,
+        #[serde(with = "hex::serde")]
+        application_nonce: Vec<u8>,
+        #[serde(with = "hex::serde")]
+        handshake_key: Vec<u8>,
+        #[serde(with = "hex::serde")]
+        handshake_nonce: Vec<u8>,
+    }
+
+    fn load_interop_test_cases() -> Vec<InteropTestCase> {
+        load_test_case_json!(secret_tree_interop, generate_test_vector())
+    }
+
+    #[cfg(not(mls_build_async))]
+    #[cfg_attr(coverage_nightly, coverage(off))]
+    fn generate_test_vector() -> Vec<InteropTestCase> {
+        let mut test_cases = vec![];
+
+        for cs in CipherSuite::all() {
+            let Some(cs) = try_test_cipher_suite_provider(*cs) else {
+                continue;
+            };
+
+            let gens = [0, 15];
+            let tree_sizes = [1, 8, 32];
+
+            for n_leaves in tree_sizes {
+                let encryption_secret = cs.random_bytes_vec(cs.kdf_extract_size()).unwrap();
+
+                let mut tree = SecretTree::new(n_leaves, Zeroizing::new(encryption_secret.clone()));
+
+                let leaves = (0..n_leaves)
+                    .map(|leaf| {
+                        gens.into_iter()
+                            .map(|gen| {
+                                let index = leaf * 2u32;
+
+                                let handshake_key = tree
+                                    .message_key_generation(&cs, index, KeyType::Handshake, gen)
+                                    .unwrap();
+
+                                let app_key = tree
+                                    .message_key_generation(&cs, index, KeyType::Application, gen)
+                                    .unwrap();
+
+                                InteropLeaf {
+                                    generation: gen,
+                                    application_key: app_key.key.to_vec(),
+                                    application_nonce: app_key.nonce.to_vec(),
+                                    handshake_key: handshake_key.key.to_vec(),
+                                    handshake_nonce: handshake_key.nonce.to_vec(),
+                                }
+                            })
+                            .collect()
+                    })
+                    .collect();
+
+                let case = InteropTestCase {
+                    cipher_suite: *cs.cipher_suite(),
+                    encryption_secret,
+                    sender_data: InteropSenderData::new(&cs),
+                    leaves,
+                };
+
+                test_cases.push(case);
+            }
+        }
+
+        test_cases
+    }
+
+    #[cfg(mls_build_async)]
+    fn generate_test_vector() -> Vec<InteropTestCase> {
+        panic!("Tests cannot be generated in async mode");
+    }
+}
diff --git a/src/group/snapshot.rs b/src/group/snapshot.rs
new file mode 100644
index 0000000..dca64f8
--- /dev/null
+++ b/src/group/snapshot.rs
@@ -0,0 +1,325 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// Copyright by contributors to this project.
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+
+use crate::{
+    client::MlsError,
+    client_config::ClientConfig,
+    group::{
+        key_schedule::KeySchedule, CommitGeneration, ConfirmationTag, Group, GroupContext,
+        GroupState, InterimTranscriptHash, ReInitProposal, TreeKemPublic,
+    },
+    tree_kem::TreeKemPrivate,
+};
+
+#[cfg(feature = "by_ref_proposal")]
+use crate::{
+    crypto::{HpkePublicKey, HpkeSecretKey},
+    group::ProposalRef,
+};
+
+#[cfg(feature = "by_ref_proposal")]
+use super::proposal_cache::{CachedProposal, ProposalCache};
+
+use mls_rs_codec::{MlsDecode, MlsEncode, MlsSize};
+
+use mls_rs_core::crypto::SignatureSecretKey;
+#[cfg(feature = "tree_index")]
+use mls_rs_core::identity::IdentityProvider;
+
+#[cfg(all(feature = "std", feature = "by_ref_proposal"))]
+use std::collections::HashMap;
+
+#[cfg(all(feature = "by_ref_proposal", not(feature = "std")))]
+use alloc::vec::Vec;
+
+use super::{cipher_suite_provider, epoch::EpochSecrets, state_repo::GroupStateRepository};
+
+#[derive(Debug, PartialEq, Clone, MlsEncode, MlsDecode, MlsSize)]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+pub(crate) struct Snapshot {
+    version: u16,
+    pub(crate) state: RawGroupState,
+    private_tree: TreeKemPrivate,
+    epoch_secrets: EpochSecrets,
+    key_schedule: KeySchedule,
+    #[cfg(all(feature = "std", feature = "by_ref_proposal"))]
+    pending_updates: HashMap<HpkePublicKey, (HpkeSecretKey, Option<SignatureSecretKey>)>,
+    #[cfg(all(not(feature = "std"), feature = "by_ref_proposal"))]
+    pending_updates: Vec<(HpkePublicKey, (HpkeSecretKey, Option<SignatureSecretKey>))>,
+    pending_commit: Option<CommitGeneration>,
+    signer: SignatureSecretKey,
+}
+
+#[derive(Debug, MlsEncode, MlsDecode, MlsSize, PartialEq, Clone)]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+pub(crate) struct RawGroupState {
+    pub(crate) context: GroupContext,
+    #[cfg(all(feature = "std", feature = "by_ref_proposal"))]
+    pub(crate) proposals: HashMap<ProposalRef, CachedProposal>,
+    #[cfg(all(not(feature = "std"), feature = "by_ref_proposal"))]
+    pub(crate) proposals: Vec<(ProposalRef, CachedProposal)>,
+    pub(crate) public_tree: TreeKemPublic,
+    pub(crate) interim_transcript_hash: InterimTranscriptHash,
+    pub(crate) pending_reinit: Option<ReInitProposal>,
+    pub(crate) confirmation_tag: ConfirmationTag,
+}
+
+impl RawGroupState {
+    pub(crate) fn export(state: &GroupState) -> Self {
+        #[cfg(feature = "tree_index")]
+        let public_tree = state.public_tree.clone();
+
+        #[cfg(not(feature = "tree_index"))]
+        let public_tree = {
+            let mut tree = TreeKemPublic::new();
+            tree.nodes = state.public_tree.nodes.clone();
+            tree
+        };
+
+        Self {
+            context: state.context.clone(),
+            #[cfg(feature = "by_ref_proposal")]
+            proposals: state.proposals.proposals.clone(),
+            public_tree,
+            interim_transcript_hash: state.interim_transcript_hash.clone(),
+            pending_reinit: state.pending_reinit.clone(),
+            confirmation_tag: state.confirmation_tag.clone(),
+        }
+    }
+
+    #[cfg(feature = "tree_index")]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(crate) async fn import<C>(self, identity_provider: &C) -> Result<GroupState, MlsError>
+    where
+        C: IdentityProvider,
+    {
+        let context = self.context;
+
+        #[cfg(feature = "by_ref_proposal")]
+        let proposals = ProposalCache::import(
+            context.protocol_version,
+            context.group_id.clone(),
+            self.proposals,
+        );
+
+        let mut public_tree = self.public_tree;
+
+        public_tree
+            .initialize_index_if_necessary(identity_provider, &context.extensions)
+            .await?;
+
+        Ok(GroupState {
+            #[cfg(feature = "by_ref_proposal")]
+            proposals,
+            context,
+            public_tree,
+            interim_transcript_hash: self.interim_transcript_hash,
+            pending_reinit: self.pending_reinit,
+            confirmation_tag: self.confirmation_tag,
+        })
+    }
+
+    #[cfg(not(feature = "tree_index"))]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(crate) async fn import(self) -> Result<GroupState, MlsError> {
+        let context = self.context;
+
+        #[cfg(feature = "by_ref_proposal")]
+        let proposals = ProposalCache::import(
+            context.protocol_version,
+            context.group_id.clone(),
+            self.proposals,
+        );
+
+        Ok(GroupState {
+            #[cfg(feature = "by_ref_proposal")]
+            proposals,
+            context,
+            public_tree: self.public_tree,
+            interim_transcript_hash: self.interim_transcript_hash,
+            pending_reinit: self.pending_reinit,
+            confirmation_tag: self.confirmation_tag,
+        })
+    }
+}
+
+impl<C> Group<C>
+where
+    C: ClientConfig + Clone,
+{
+    /// Write the current state of the group to the
+    /// [`GroupStorageProvider`](crate::GroupStateStorage)
+    /// that is currently in use by the group.
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn write_to_storage(&mut self) -> Result<(), MlsError> {
+        self.state_repo.write_to_storage(self.snapshot()).await
+    }
+
+    pub(crate) fn snapshot(&self) -> Snapshot {
+        Snapshot {
+            state: RawGroupState::export(&self.state),
+            private_tree: self.private_tree.clone(),
+            key_schedule: self.key_schedule.clone(),
+            #[cfg(feature = "by_ref_proposal")]
+            pending_updates: self.pending_updates.clone(),
+            pending_commit: self.pending_commit.clone(),
+            epoch_secrets: self.epoch_secrets.clone(),
+            version: 1,
+            signer: self.signer.clone(),
+        }
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(crate) async fn from_snapshot(config: C, snapshot: Snapshot) -> Result<Self, MlsError> {
+        let cipher_suite_provider = cipher_suite_provider(
+            config.crypto_provider(),
+            snapshot.state.context.cipher_suite,
+        )?;
+
+        #[cfg(feature = "tree_index")]
+        let identity_provider = config.identity_provider();
+
+        let state_repo = GroupStateRepository::new(
+            #[cfg(feature = "prior_epoch")]
+            snapshot.state.context.group_id.clone(),
+            config.group_state_storage(),
+            config.key_package_repo(),
+            None,
+        )?;
+
+        Ok(Group {
+            config,
+            state: snapshot
+                .state
+                .import(
+                    #[cfg(feature = "tree_index")]
+                    &identity_provider,
+                )
+                .await?,
+            private_tree: snapshot.private_tree,
+            key_schedule: snapshot.key_schedule,
+            #[cfg(feature = "by_ref_proposal")]
+            pending_updates: snapshot.pending_updates,
+            pending_commit: snapshot.pending_commit,
+            #[cfg(test)]
+            commit_modifiers: Default::default(),
+            epoch_secrets: snapshot.epoch_secrets,
+            state_repo,
+            cipher_suite_provider,
+            #[cfg(feature = "psk")]
+            previous_psk: None,
+            signer: snapshot.signer,
+        })
+    }
+}
+
+#[cfg(test)]
+pub(crate) mod test_utils {
+    use alloc::vec;
+
+    use crate::{
+        cipher_suite::CipherSuite,
+        crypto::test_utils::test_cipher_suite_provider,
+        group::{
+            confirmation_tag::ConfirmationTag, epoch::test_utils::get_test_epoch_secrets,
+            key_schedule::test_utils::get_test_key_schedule, test_utils::get_test_group_context,
+            transcript_hash::InterimTranscriptHash,
+        },
+        tree_kem::{node::LeafIndex, TreeKemPrivate},
+    };
+
+    use super::{RawGroupState, Snapshot};
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(crate) async fn get_test_snapshot(cipher_suite: CipherSuite, epoch_id: u64) -> Snapshot {
+        Snapshot {
+            state: RawGroupState {
+                context: get_test_group_context(epoch_id, cipher_suite).await,
+                #[cfg(feature = "by_ref_proposal")]
+                proposals: Default::default(),
+                public_tree: Default::default(),
+                interim_transcript_hash: InterimTranscriptHash::from(vec![]),
+                pending_reinit: None,
+                confirmation_tag: ConfirmationTag::empty(&test_cipher_suite_provider(cipher_suite))
+                    .await,
+            },
+            private_tree: TreeKemPrivate::new(LeafIndex(0)),
+            epoch_secrets: get_test_epoch_secrets(cipher_suite),
+            key_schedule: get_test_key_schedule(cipher_suite),
+            #[cfg(feature = "by_ref_proposal")]
+            pending_updates: Default::default(),
+            pending_commit: None,
+            version: 1,
+            signer: vec![].into(),
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use alloc::vec;
+
+    use crate::{
+        client::test_utils::{TEST_CIPHER_SUITE, TEST_PROTOCOL_VERSION},
+        group::{
+            test_utils::{test_group, TestGroup},
+            Group,
+        },
+    };
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn snapshot_restore(group: TestGroup) {
+        let snapshot = group.group.snapshot();
+
+        let group_restored = Group::from_snapshot(group.group.config.clone(), snapshot)
+            .await
+            .unwrap();
+
+        assert!(Group::equal_group_state(&group.group, &group_restored));
+
+        #[cfg(feature = "tree_index")]
+        assert!(group_restored
+            .state
+            .public_tree
+            .equal_internals(&group.group.state.public_tree))
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn snapshot_with_pending_commit_can_be_serialized_to_json() {
+        let mut group = test_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await;
+        group.group.commit(vec![]).await.unwrap();
+
+        snapshot_restore(group).await
+    }
+
+    #[cfg(feature = "by_ref_proposal")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn snapshot_with_pending_updates_can_be_serialized_to_json() {
+        let mut group = test_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await;
+
+        // Creating the update proposal will add it to pending updates
+        let update_proposal = group.update_proposal().await;
+
+        // This will insert the proposal into the internal proposal cache
+        let _ = group.group.proposal_message(update_proposal, vec![]).await;
+
+        snapshot_restore(group).await
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn snapshot_can_be_serialized_to_json_with_internals() {
+        let group = test_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await;
+
+        snapshot_restore(group).await
+    }
+
+    #[cfg(feature = "serde")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn serde() {
+        let snapshot = super::test_utils::get_test_snapshot(TEST_CIPHER_SUITE, 5).await;
+        let json = serde_json::to_string_pretty(&snapshot).unwrap();
+        let recovered = serde_json::from_str(&json).unwrap();
+        assert_eq!(snapshot, recovered);
+    }
+}
diff --git a/src/group/state.rs b/src/group/state.rs
new file mode 100644
index 0000000..4d97a04
--- /dev/null
+++ b/src/group/state.rs
@@ -0,0 +1,43 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// Copyright by contributors to this project.
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+
+use super::{
+    confirmation_tag::ConfirmationTag, proposal::ReInitProposal,
+    transcript_hash::InterimTranscriptHash,
+};
+use crate::group::{GroupContext, TreeKemPublic};
+
+#[derive(Clone, Debug, PartialEq)]
+#[non_exhaustive]
+pub struct GroupState {
+    #[cfg(feature = "by_ref_proposal")]
+    pub(crate) proposals: crate::group::ProposalCache,
+    pub(crate) context: GroupContext,
+    pub(crate) public_tree: TreeKemPublic,
+    pub(crate) interim_transcript_hash: InterimTranscriptHash,
+    pub(crate) pending_reinit: Option<ReInitProposal>,
+    pub(crate) confirmation_tag: ConfirmationTag,
+}
+
+impl GroupState {
+    pub(crate) fn new(
+        context: GroupContext,
+        current_tree: TreeKemPublic,
+        interim_transcript_hash: InterimTranscriptHash,
+        confirmation_tag: ConfirmationTag,
+    ) -> Self {
+        Self {
+            #[cfg(feature = "by_ref_proposal")]
+            proposals: crate::group::ProposalCache::new(
+                context.protocol_version,
+                context.group_id.clone(),
+            ),
+            context,
+            public_tree: current_tree,
+            interim_transcript_hash,
+            pending_reinit: None,
+            confirmation_tag,
+        }
+    }
+}
diff --git a/src/group/state_repo.rs b/src/group/state_repo.rs
new file mode 100644
index 0000000..6e33b0a
--- /dev/null
+++ b/src/group/state_repo.rs
@@ -0,0 +1,573 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// Copyright by contributors to this project.
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+
+use crate::client::MlsError;
+use crate::{group::PriorEpoch, key_package::KeyPackageRef};
+
+use alloc::collections::VecDeque;
+use alloc::vec::Vec;
+use core::fmt::{self, Debug};
+use mls_rs_codec::{MlsDecode, MlsEncode};
+use mls_rs_core::group::{EpochRecord, GroupState};
+use mls_rs_core::{error::IntoAnyError, group::GroupStateStorage, key_package::KeyPackageStorage};
+
+use super::snapshot::Snapshot;
+
+#[cfg(feature = "psk")]
+use crate::group::ResumptionPsk;
+
+#[cfg(feature = "psk")]
+use mls_rs_core::psk::PreSharedKey;
+
+/// A set of changes to apply to a GroupStateStorage implementation. These changes MUST
+/// be made in a single transaction to avoid creating invalid states.
+#[derive(Default, Clone, Debug)]
+struct EpochStorageCommit {
+    pub(crate) inserts: VecDeque<PriorEpoch>,
+    pub(crate) updates: Vec<PriorEpoch>,
+}
+
+#[derive(Clone)]
+pub(crate) struct GroupStateRepository<S, K>
+where
+    S: GroupStateStorage,
+    K: KeyPackageStorage,
+{
+    pending_commit: EpochStorageCommit,
+    pending_key_package_removal: Option<KeyPackageRef>,
+    group_id: Vec<u8>,
+    storage: S,
+    key_package_repo: K,
+}
+
+impl<S, K> Debug for GroupStateRepository<S, K>
+where
+    S: GroupStateStorage + Debug,
+    K: KeyPackageStorage + Debug,
+{
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.debug_struct("GroupStateRepository")
+            .field("pending_commit", &self.pending_commit)
+            .field(
+                "pending_key_package_removal",
+                &self.pending_key_package_removal,
+            )
+            .field(
+                "group_id",
+                &mls_rs_core::debug::pretty_group_id(&self.group_id),
+            )
+            .field("storage", &self.storage)
+            .field("key_package_repo", &self.key_package_repo)
+            .finish()
+    }
+}
+
+impl<S, K> GroupStateRepository<S, K>
+where
+    S: GroupStateStorage,
+    K: KeyPackageStorage,
+{
+    pub fn new(
+        group_id: Vec<u8>,
+        storage: S,
+        key_package_repo: K,
+        // Set to `None` if restoring from snapshot; set to `Some` when joining a group.
+        key_package_to_remove: Option<KeyPackageRef>,
+    ) -> Result<GroupStateRepository<S, K>, MlsError> {
+        Ok(GroupStateRepository {
+            group_id,
+            storage,
+            pending_key_package_removal: key_package_to_remove,
+            pending_commit: Default::default(),
+            key_package_repo,
+        })
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn find_max_id(&self) -> Result<Option<u64>, MlsError> {
+        if let Some(max) = self.pending_commit.inserts.back().map(|e| e.epoch_id()) {
+            Ok(Some(max))
+        } else {
+            self.storage
+                .max_epoch_id(&self.group_id)
+                .await
+                .map_err(|e| MlsError::GroupStorageError(e.into_any_error()))
+        }
+    }
+
+    #[cfg(feature = "psk")]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn resumption_secret(
+        &self,
+        psk_id: &ResumptionPsk,
+    ) -> Result<Option<PreSharedKey>, MlsError> {
+        // Search the local inserts cache
+        if let Some(min) = self.pending_commit.inserts.front().map(|e| e.epoch_id()) {
+            if psk_id.psk_epoch >= min {
+                return Ok(self
+                    .pending_commit
+                    .inserts
+                    .get((psk_id.psk_epoch - min) as usize)
+                    .map(|e| e.secrets.resumption_secret.clone()));
+            }
+        }
+
+        // Search the local updates cache
+        let maybe_pending = self.find_pending(psk_id.psk_epoch);
+
+        if let Some(pending) = maybe_pending {
+            return Ok(Some(
+                self.pending_commit.updates[pending]
+                    .secrets
+                    .resumption_secret
+                    .clone(),
+            ));
+        }
+
+        // Search the stored cache
+        self.storage
+            .epoch(&psk_id.psk_group_id.0, psk_id.psk_epoch)
+            .await
+            .map_err(|e| MlsError::GroupStorageError(e.into_any_error()))?
+            .map(|e| Ok(PriorEpoch::mls_decode(&mut &*e)?.secrets.resumption_secret))
+            .transpose()
+    }
+
+    #[cfg(feature = "private_message")]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn get_epoch_mut(
+        &mut self,
+        epoch_id: u64,
+    ) -> Result<Option<&mut PriorEpoch>, MlsError> {
+        // Search the local inserts cache
+        if let Some(min) = self.pending_commit.inserts.front().map(|e| e.epoch_id()) {
+            if epoch_id >= min {
+                return Ok(self
+                    .pending_commit
+                    .inserts
+                    .get_mut((epoch_id - min) as usize));
+            }
+        }
+
+        // Look in the cached updates map, and if not found look in disk storage
+        // and insert into the updates map for future caching
+        match self.find_pending(epoch_id) {
+            Some(i) => self.pending_commit.updates.get_mut(i).map(Ok),
+            None => self
+                .storage
+                .epoch(&self.group_id, epoch_id)
+                .await
+                .map_err(|e| MlsError::GroupStorageError(e.into_any_error()))?
+                .and_then(|epoch| {
+                    PriorEpoch::mls_decode(&mut &*epoch)
+                        .map(|epoch| {
+                            self.pending_commit.updates.push(epoch);
+                            self.pending_commit.updates.last_mut()
+                        })
+                        .transpose()
+                }),
+        }
+        .transpose()
+        .map_err(Into::into)
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn insert(&mut self, epoch: PriorEpoch) -> Result<(), MlsError> {
+        if epoch.group_id() != self.group_id {
+            return Err(MlsError::GroupIdMismatch);
+        }
+
+        let epoch_id = epoch.epoch_id();
+
+        if let Some(expected_id) = self.find_max_id().await?.map(|id| id + 1) {
+            if epoch_id != expected_id {
+                return Err(MlsError::InvalidEpoch);
+            }
+        }
+
+        self.pending_commit.inserts.push_back(epoch);
+
+        Ok(())
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn write_to_storage(&mut self, group_snapshot: Snapshot) -> Result<(), MlsError> {
+        let inserts = self
+            .pending_commit
+            .inserts
+            .iter()
+            .map(|e| Ok(EpochRecord::new(e.epoch_id(), e.mls_encode_to_vec()?)))
+            .collect::<Result<_, MlsError>>()?;
+
+        let updates = self
+            .pending_commit
+            .updates
+            .iter()
+            .map(|e| Ok(EpochRecord::new(e.epoch_id(), e.mls_encode_to_vec()?)))
+            .collect::<Result<_, MlsError>>()?;
+
+        let group_state = GroupState {
+            data: group_snapshot.mls_encode_to_vec()?,
+            id: group_snapshot.state.context.group_id,
+        };
+
+        self.storage
+            .write(group_state, inserts, updates)
+            .await
+            .map_err(|e| MlsError::GroupStorageError(e.into_any_error()))?;
+
+        if let Some(ref key_package_ref) = self.pending_key_package_removal {
+            self.key_package_repo
+                .delete(key_package_ref)
+                .await
+                .map_err(|e| MlsError::KeyPackageRepoError(e.into_any_error()))?;
+        }
+
+        self.pending_commit.inserts.clear();
+        self.pending_commit.updates.clear();
+
+        Ok(())
+    }
+
+    #[cfg(any(feature = "psk", feature = "private_message"))]
+    fn find_pending(&self, epoch_id: u64) -> Option<usize> {
+        self.pending_commit
+            .updates
+            .iter()
+            .position(|ep| ep.context.epoch == epoch_id)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use alloc::vec;
+    use mls_rs_codec::MlsEncode;
+
+    use crate::{
+        client::test_utils::{TEST_CIPHER_SUITE, TEST_PROTOCOL_VERSION},
+        group::{
+            epoch::{test_utils::get_test_epoch_with_id, SenderDataSecret},
+            test_utils::{random_bytes, test_member, TEST_GROUP},
+            PskGroupId, ResumptionPSKUsage,
+        },
+        storage_provider::in_memory::{InMemoryGroupStateStorage, InMemoryKeyPackageStorage},
+    };
+
+    use super::*;
+
+    fn test_group_state_repo(
+        retention_limit: usize,
+    ) -> GroupStateRepository<InMemoryGroupStateStorage, InMemoryKeyPackageStorage> {
+        GroupStateRepository::new(
+            TEST_GROUP.to_vec(),
+            InMemoryGroupStateStorage::new()
+                .with_max_epoch_retention(retention_limit)
+                .unwrap(),
+            InMemoryKeyPackageStorage::default(),
+            None,
+        )
+        .unwrap()
+    }
+
+    fn test_epoch(epoch_id: u64) -> PriorEpoch {
+        get_test_epoch_with_id(TEST_GROUP.to_vec(), TEST_CIPHER_SUITE, epoch_id)
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn test_snapshot(epoch_id: u64) -> Snapshot {
+        crate::group::snapshot::test_utils::get_test_snapshot(TEST_CIPHER_SUITE, epoch_id).await
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_epoch_inserts() {
+        let mut test_repo = test_group_state_repo(1);
+        let test_epoch = test_epoch(0);
+
+        test_repo.insert(test_epoch.clone()).await.unwrap();
+
+        // Check the in-memory state
+        assert_eq!(
+            test_repo.pending_commit.inserts.back().unwrap(),
+            &test_epoch
+        );
+
+        assert!(test_repo.pending_commit.updates.is_empty());
+
+        #[cfg(feature = "std")]
+        assert!(test_repo.storage.inner.lock().unwrap().is_empty());
+        #[cfg(not(feature = "std"))]
+        assert!(test_repo.storage.inner.lock().is_empty());
+
+        let psk_id = ResumptionPsk {
+            psk_epoch: 0,
+            psk_group_id: PskGroupId(test_repo.group_id.clone()),
+            usage: ResumptionPSKUsage::Application,
+        };
+
+        // Make sure you can recall an epoch sitting as a pending insert
+        let resumption = test_repo.resumption_secret(&psk_id).await.unwrap();
+        let prior_epoch = test_repo.get_epoch_mut(0).await.unwrap().cloned();
+
+        assert_eq!(
+            prior_epoch.clone().unwrap().secrets.resumption_secret,
+            resumption.unwrap()
+        );
+
+        assert_eq!(prior_epoch.unwrap(), test_epoch);
+
+        // Write to the storage
+        let snapshot = test_snapshot(test_epoch.epoch_id()).await;
+        test_repo.write_to_storage(snapshot.clone()).await.unwrap();
+
+        // Make sure the memory cache cleared
+        assert!(test_repo.pending_commit.inserts.is_empty());
+        assert!(test_repo.pending_commit.updates.is_empty());
+
+        // Make sure the storage was written
+        #[cfg(feature = "std")]
+        let storage = test_repo.storage.inner.lock().unwrap();
+        #[cfg(not(feature = "std"))]
+        let storage = test_repo.storage.inner.lock();
+
+        assert_eq!(storage.len(), 1);
+
+        let stored = storage.get(TEST_GROUP).unwrap();
+
+        assert_eq!(stored.state_data, snapshot.mls_encode_to_vec().unwrap());
+
+        assert_eq!(stored.epoch_data.len(), 1);
+
+        assert_eq!(
+            stored.epoch_data.back().unwrap(),
+            &EpochRecord::new(
+                test_epoch.epoch_id(),
+                test_epoch.mls_encode_to_vec().unwrap()
+            )
+        );
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_updates() {
+        let mut test_repo = test_group_state_repo(2);
+        let test_epoch_0 = test_epoch(0);
+
+        test_repo.insert(test_epoch_0.clone()).await.unwrap();
+
+        test_repo
+            .write_to_storage(test_snapshot(0).await)
+            .await
+            .unwrap();
+
+        // Update the stored epoch
+        let to_update = test_repo.get_epoch_mut(0).await.unwrap().unwrap();
+        assert_eq!(to_update, &test_epoch_0);
+
+        let new_sender_secret = random_bytes(32);
+        to_update.secrets.sender_data_secret = SenderDataSecret::from(new_sender_secret);
+        let to_update = to_update.clone();
+
+        assert_eq!(test_repo.pending_commit.updates.len(), 1);
+        assert!(test_repo.pending_commit.inserts.is_empty());
+
+        assert_eq!(
+            test_repo.pending_commit.updates.first().unwrap(),
+            &to_update
+        );
+
+        // Make sure you can access an epoch pending update
+        let psk_id = ResumptionPsk {
+            psk_epoch: 0,
+            psk_group_id: PskGroupId(test_repo.group_id.clone()),
+            usage: ResumptionPSKUsage::Application,
+        };
+
+        let owned = test_repo.resumption_secret(&psk_id).await.unwrap();
+        assert_eq!(owned.as_ref(), Some(&to_update.secrets.resumption_secret));
+
+        // Write the update to storage
+        let snapshot = test_snapshot(1).await;
+        test_repo.write_to_storage(snapshot.clone()).await.unwrap();
+
+        assert!(test_repo.pending_commit.updates.is_empty());
+        assert!(test_repo.pending_commit.inserts.is_empty());
+
+        // Make sure the storage was written
+        #[cfg(feature = "std")]
+        let storage = test_repo.storage.inner.lock().unwrap();
+        #[cfg(not(feature = "std"))]
+        let storage = test_repo.storage.inner.lock();
+
+        assert_eq!(storage.len(), 1);
+
+        let stored = storage.get(TEST_GROUP).unwrap();
+
+        assert_eq!(stored.state_data, snapshot.mls_encode_to_vec().unwrap());
+
+        assert_eq!(stored.epoch_data.len(), 1);
+
+        assert_eq!(
+            stored.epoch_data.back().unwrap(),
+            &EpochRecord::new(to_update.epoch_id(), to_update.mls_encode_to_vec().unwrap())
+        );
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_insert_and_update() {
+        let mut test_repo = test_group_state_repo(2);
+        let test_epoch_0 = test_epoch(0);
+
+        test_repo.insert(test_epoch_0).await.unwrap();
+
+        test_repo
+            .write_to_storage(test_snapshot(0).await)
+            .await
+            .unwrap();
+
+        // Update the stored epoch
+        let to_update = test_repo.get_epoch_mut(0).await.unwrap().unwrap();
+        let new_sender_secret = random_bytes(32);
+        to_update.secrets.sender_data_secret = SenderDataSecret::from(new_sender_secret);
+        let to_update = to_update.clone();
+
+        // Insert another epoch
+        let test_epoch_1 = test_epoch(1);
+        test_repo.insert(test_epoch_1.clone()).await.unwrap();
+
+        test_repo
+            .write_to_storage(test_snapshot(1).await)
+            .await
+            .unwrap();
+
+        assert!(test_repo.pending_commit.inserts.is_empty());
+        assert!(test_repo.pending_commit.updates.is_empty());
+
+        // Make sure the storage was written
+        #[cfg(feature = "std")]
+        let storage = test_repo.storage.inner.lock().unwrap();
+        #[cfg(not(feature = "std"))]
+        let storage = test_repo.storage.inner.lock();
+
+        assert_eq!(storage.len(), 1);
+
+        let stored = storage.get(TEST_GROUP).unwrap();
+
+        assert_eq!(stored.epoch_data.len(), 2);
+
+        assert_eq!(
+            stored.epoch_data.front().unwrap(),
+            &EpochRecord::new(to_update.epoch_id(), to_update.mls_encode_to_vec().unwrap())
+        );
+
+        assert_eq!(
+            stored.epoch_data.back().unwrap(),
+            &EpochRecord::new(
+                test_epoch_1.epoch_id(),
+                test_epoch_1.mls_encode_to_vec().unwrap()
+            )
+        );
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_many_epochs_in_storage() {
+        let epochs = (0..10).map(test_epoch).collect::<Vec<_>>();
+
+        let mut test_repo = test_group_state_repo(10);
+
+        for epoch in epochs.iter().cloned() {
+            test_repo.insert(epoch).await.unwrap()
+        }
+
+        test_repo
+            .write_to_storage(test_snapshot(9).await)
+            .await
+            .unwrap();
+
+        for mut epoch in epochs {
+            let res = test_repo.get_epoch_mut(epoch.epoch_id()).await.unwrap();
+
+            assert_eq!(res, Some(&mut epoch));
+        }
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_stored_groups_list() {
+        let mut test_repo = test_group_state_repo(2);
+        let test_epoch_0 = test_epoch(0);
+
+        test_repo.insert(test_epoch_0.clone()).await.unwrap();
+
+        test_repo
+            .write_to_storage(test_snapshot(0).await)
+            .await
+            .unwrap();
+
+        assert_eq!(
+            test_repo.storage.stored_groups(),
+            vec![test_epoch_0.context.group_id]
+        )
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn reducing_retention_limit_takes_effect_on_epoch_access() {
+        let mut repo = test_group_state_repo(1);
+
+        repo.insert(test_epoch(0)).await.unwrap();
+        repo.insert(test_epoch(1)).await.unwrap();
+
+        repo.write_to_storage(test_snapshot(0).await).await.unwrap();
+
+        let mut repo = GroupStateRepository {
+            storage: repo.storage,
+            ..test_group_state_repo(1)
+        };
+
+        let res = repo.get_epoch_mut(0).await.unwrap();
+
+        assert!(res.is_none());
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn in_memory_storage_obeys_retention_limit_after_saving() {
+        let mut repo = test_group_state_repo(1);
+
+        repo.insert(test_epoch(0)).await.unwrap();
+        repo.write_to_storage(test_snapshot(0).await).await.unwrap();
+        repo.insert(test_epoch(1)).await.unwrap();
+        repo.write_to_storage(test_snapshot(1).await).await.unwrap();
+
+        #[cfg(feature = "std")]
+        let lock = repo.storage.inner.lock().unwrap();
+        #[cfg(not(feature = "std"))]
+        let lock = repo.storage.inner.lock();
+
+        assert_eq!(lock.get(TEST_GROUP).unwrap().epoch_data.len(), 1);
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn used_key_package_is_deleted() {
+        let key_package_repo = InMemoryKeyPackageStorage::default();
+
+        let key_package = test_member(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, b"member")
+            .await
+            .0;
+
+        let (id, data) = key_package.to_storage().unwrap();
+
+        key_package_repo.insert(id, data);
+
+        let mut repo = GroupStateRepository::new(
+            TEST_GROUP.to_vec(),
+            InMemoryGroupStateStorage::new(),
+            key_package_repo,
+            Some(key_package.reference.clone()),
+        )
+        .unwrap();
+
+        repo.key_package_repo.get(&key_package.reference).unwrap();
+
+        repo.write_to_storage(test_snapshot(4).await).await.unwrap();
+
+        assert!(repo.key_package_repo.get(&key_package.reference).is_none());
+    }
+}
diff --git a/src/group/state_repo_light.rs b/src/group/state_repo_light.rs
new file mode 100644
index 0000000..76d1fb6
--- /dev/null
+++ b/src/group/state_repo_light.rs
@@ -0,0 +1,132 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// Copyright by contributors to this project.
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+
+use crate::client::MlsError;
+use crate::key_package::KeyPackageRef;
+
+use alloc::vec::Vec;
+use mls_rs_codec::MlsEncode;
+use mls_rs_core::{
+    error::IntoAnyError,
+    group::{GroupState, GroupStateStorage},
+    key_package::KeyPackageStorage,
+};
+
+use super::snapshot::Snapshot;
+
+#[derive(Debug, Clone)]
+pub(crate) struct GroupStateRepository<S, K>
+where
+    S: GroupStateStorage,
+    K: KeyPackageStorage,
+{
+    pending_key_package_removal: Option<KeyPackageRef>,
+    storage: S,
+    key_package_repo: K,
+}
+
+impl<S, K> GroupStateRepository<S, K>
+where
+    S: GroupStateStorage,
+    K: KeyPackageStorage,
+{
+    pub fn new(
+        storage: S,
+        key_package_repo: K,
+        // Set to `None` if restoring from snapshot; set to `Some` when joining a group.
+        key_package_to_remove: Option<KeyPackageRef>,
+    ) -> Result<GroupStateRepository<S, K>, MlsError> {
+        Ok(GroupStateRepository {
+            storage,
+            pending_key_package_removal: key_package_to_remove,
+            key_package_repo,
+        })
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn write_to_storage(&mut self, group_snapshot: Snapshot) -> Result<(), MlsError> {
+        let group_state = GroupState {
+            data: group_snapshot.mls_encode_to_vec()?,
+            id: group_snapshot.state.context.group_id,
+        };
+
+        self.storage
+            .write(group_state, Vec::new(), Vec::new())
+            .await
+            .map_err(|e| MlsError::GroupStorageError(e.into_any_error()))?;
+
+        if let Some(ref key_package_ref) = self.pending_key_package_removal {
+            self.key_package_repo
+                .delete(key_package_ref)
+                .await
+                .map_err(|e| MlsError::KeyPackageRepoError(e.into_any_error()))?;
+        }
+
+        Ok(())
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use crate::{
+        client::test_utils::{TEST_CIPHER_SUITE, TEST_PROTOCOL_VERSION},
+        group::{
+            snapshot::{test_utils::get_test_snapshot, Snapshot},
+            test_utils::{test_member, TEST_GROUP},
+        },
+        storage_provider::in_memory::{InMemoryGroupStateStorage, InMemoryKeyPackageStorage},
+    };
+
+    use alloc::vec;
+
+    use super::GroupStateRepository;
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn test_snapshot(epoch_id: u64) -> Snapshot {
+        get_test_snapshot(TEST_CIPHER_SUITE, epoch_id).await
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_stored_groups_list() {
+        let mut test_repo = GroupStateRepository::new(
+            InMemoryGroupStateStorage::default(),
+            InMemoryKeyPackageStorage::default(),
+            None,
+        )
+        .unwrap();
+
+        test_repo
+            .write_to_storage(test_snapshot(0).await)
+            .await
+            .unwrap();
+
+        assert_eq!(test_repo.storage.stored_groups(), vec![TEST_GROUP])
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn used_key_package_is_deleted() {
+        let key_package_repo = InMemoryKeyPackageStorage::default();
+
+        let key_package = test_member(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, b"member")
+            .await
+            .0;
+
+        let (id, data) = key_package.to_storage().unwrap();
+
+        key_package_repo.insert(id, data);
+
+        let mut repo = GroupStateRepository::new(
+            InMemoryGroupStateStorage::default(),
+            key_package_repo,
+            Some(key_package.reference.clone()),
+        )
+        .unwrap();
+
+        repo.key_package_repo.get(&key_package.reference).unwrap();
+
+        repo.write_to_storage(test_snapshot(4).await).await.unwrap();
+
+        assert!(repo.key_package_repo.get(&key_package.reference).is_none());
+    }
+}
diff --git a/src/group/test_utils.rs b/src/group/test_utils.rs
new file mode 100644
index 0000000..764d5e6
--- /dev/null
+++ b/src/group/test_utils.rs
@@ -0,0 +1,521 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// Copyright by contributors to this project.
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+
+use core::ops::{Deref, DerefMut};
+
+use alloc::format;
+use rand::RngCore;
+
+use super::*;
+use crate::{
+    client::{
+        test_utils::{
+            test_client_with_key_pkg, test_client_with_key_pkg_custom, TEST_CIPHER_SUITE,
+            TEST_PROTOCOL_VERSION,
+        },
+        MlsError,
+    },
+    client_builder::test_utils::{TestClientBuilder, TestClientConfig},
+    crypto::test_utils::test_cipher_suite_provider,
+    extension::ExtensionType,
+    identity::basic::BasicIdentityProvider,
+    identity::test_utils::get_test_signing_identity,
+    key_package::{KeyPackageGeneration, KeyPackageGenerator},
+    mls_rules::{CommitOptions, DefaultMlsRules},
+    tree_kem::{leaf_node::test_utils::get_test_capabilities, Lifetime},
+};
+
+use crate::extension::RequiredCapabilitiesExt;
+
+#[cfg(not(feature = "by_ref_proposal"))]
+use crate::crypto::HpkePublicKey;
+
+pub const TEST_GROUP: &[u8] = b"group";
+
+#[derive(Clone)]
+pub(crate) struct TestGroup {
+    pub group: Group<TestClientConfig>,
+}
+
+impl TestGroup {
+    #[cfg(feature = "external_client")]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(crate) async fn propose(&mut self, proposal: Proposal) -> MlsMessage {
+        self.group.proposal_message(proposal, vec![]).await.unwrap()
+    }
+
+    #[cfg(feature = "by_ref_proposal")]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(crate) async fn update_proposal(&mut self) -> Proposal {
+        self.group.update_proposal(None, None).await.unwrap()
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(crate) async fn join_with_custom_config<F>(
+        &mut self,
+        name: &str,
+        custom_kp: bool,
+        mut config: F,
+    ) -> Result<(TestGroup, MlsMessage), MlsError>
+    where
+        F: FnMut(&mut TestClientConfig),
+    {
+        let (mut new_client, new_key_package) = if custom_kp {
+            test_client_with_key_pkg_custom(
+                self.group.protocol_version(),
+                self.group.cipher_suite(),
+                name,
+                &mut config,
+            )
+            .await
+        } else {
+            test_client_with_key_pkg(
+                self.group.protocol_version(),
+                self.group.cipher_suite(),
+                name,
+            )
+            .await
+        };
+
+        // Add new member to the group
+        let CommitOutput {
+            welcome_messages,
+            ratchet_tree,
+            commit_message,
+            ..
+        } = self
+            .group
+            .commit_builder()
+            .add_member(new_key_package)
+            .unwrap()
+            .build()
+            .await
+            .unwrap();
+
+        // Apply the commit to the original group
+        self.group.apply_pending_commit().await.unwrap();
+
+        config(&mut new_client.config);
+
+        // Group from new member's perspective
+        let (new_group, _) = Group::join(
+            &welcome_messages[0],
+            ratchet_tree,
+            new_client.config.clone(),
+            new_client.signer.clone().unwrap(),
+        )
+        .await?;
+
+        let new_test_group = TestGroup { group: new_group };
+
+        Ok((new_test_group, commit_message))
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(crate) async fn join(&mut self, name: &str) -> (TestGroup, MlsMessage) {
+        self.join_with_custom_config(name, false, |_| ())
+            .await
+            .unwrap()
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(crate) async fn process_pending_commit(
+        &mut self,
+    ) -> Result<CommitMessageDescription, MlsError> {
+        self.group.apply_pending_commit().await
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(crate) async fn process_message(
+        &mut self,
+        message: MlsMessage,
+    ) -> Result<ReceivedMessage, MlsError> {
+        self.group.process_incoming_message(message).await
+    }
+
+    #[cfg(feature = "private_message")]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(crate) async fn make_plaintext(&mut self, content: Content) -> MlsMessage {
+        let auth_content = AuthenticatedContent::new_signed(
+            &self.group.cipher_suite_provider,
+            &self.group.state.context,
+            Sender::Member(*self.group.private_tree.self_index),
+            content,
+            &self.group.signer,
+            WireFormat::PublicMessage,
+            Vec::new(),
+        )
+        .await
+        .unwrap();
+
+        self.group.format_for_wire(auth_content).await.unwrap()
+    }
+}
+
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+pub(crate) async fn get_test_group_context(epoch: u64, cipher_suite: CipherSuite) -> GroupContext {
+    let cs = test_cipher_suite_provider(cipher_suite);
+
+    GroupContext {
+        protocol_version: TEST_PROTOCOL_VERSION,
+        cipher_suite,
+        group_id: TEST_GROUP.to_vec(),
+        epoch,
+        tree_hash: cs.hash(&[1, 2, 3]).await.unwrap(),
+        confirmed_transcript_hash: cs.hash(&[3, 2, 1]).await.unwrap().into(),
+        extensions: ExtensionList::from(vec![]),
+    }
+}
+
+#[cfg(feature = "prior_epoch")]
+pub(crate) fn get_test_group_context_with_id(
+    group_id: Vec<u8>,
+    epoch: u64,
+    cipher_suite: CipherSuite,
+) -> GroupContext {
+    GroupContext {
+        protocol_version: TEST_PROTOCOL_VERSION,
+        cipher_suite,
+        group_id,
+        epoch,
+        tree_hash: vec![],
+        confirmed_transcript_hash: ConfirmedTranscriptHash::from(vec![]),
+        extensions: ExtensionList::from(vec![]),
+    }
+}
+
+pub(crate) fn group_extensions() -> ExtensionList {
+    let required_capabilities = RequiredCapabilitiesExt::default();
+
+    let mut extensions = ExtensionList::new();
+    extensions.set_from(required_capabilities).unwrap();
+    extensions
+}
+
+pub(crate) fn lifetime() -> Lifetime {
+    Lifetime::years(1).unwrap()
+}
+
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+pub(crate) async fn test_member(
+    protocol_version: ProtocolVersion,
+    cipher_suite: CipherSuite,
+    identifier: &[u8],
+) -> (KeyPackageGeneration, SignatureSecretKey) {
+    let (signing_identity, signing_key) = get_test_signing_identity(cipher_suite, identifier).await;
+
+    let key_package_generator = KeyPackageGenerator {
+        protocol_version,
+        cipher_suite_provider: &test_cipher_suite_provider(cipher_suite),
+        signing_identity: &signing_identity,
+        signing_key: &signing_key,
+        identity_provider: &BasicIdentityProvider,
+    };
+
+    let key_package = key_package_generator
+        .generate(
+            lifetime(),
+            get_test_capabilities(),
+            ExtensionList::default(),
+            ExtensionList::default(),
+        )
+        .await
+        .unwrap();
+
+    (key_package, signing_key)
+}
+
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+pub(crate) async fn test_group_custom(
+    protocol_version: ProtocolVersion,
+    cipher_suite: CipherSuite,
+    extension_types: Vec<ExtensionType>,
+    leaf_extensions: Option<ExtensionList>,
+    commit_options: Option<CommitOptions>,
+) -> TestGroup {
+    let leaf_extensions = leaf_extensions.unwrap_or_default();
+    let commit_options = commit_options.unwrap_or_default();
+
+    let (signing_identity, secret_key) = get_test_signing_identity(cipher_suite, b"member").await;
+
+    let group = TestClientBuilder::new_for_test()
+        .leaf_node_extensions(leaf_extensions)
+        .mls_rules(DefaultMlsRules::default().with_commit_options(commit_options))
+        .extension_types(extension_types)
+        .protocol_versions(ProtocolVersion::all())
+        .used_protocol_version(protocol_version)
+        .signing_identity(signing_identity.clone(), secret_key, cipher_suite)
+        .build()
+        .create_group_with_id(TEST_GROUP.to_vec(), group_extensions())
+        .await
+        .unwrap();
+
+    TestGroup { group }
+}
+
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+pub(crate) async fn test_group(
+    protocol_version: ProtocolVersion,
+    cipher_suite: CipherSuite,
+) -> TestGroup {
+    test_group_custom(
+        protocol_version,
+        cipher_suite,
+        Default::default(),
+        None,
+        None,
+    )
+    .await
+}
+
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+pub(crate) async fn test_group_custom_config<F>(
+    protocol_version: ProtocolVersion,
+    cipher_suite: CipherSuite,
+    custom: F,
+) -> TestGroup
+where
+    F: FnOnce(TestClientBuilder) -> TestClientBuilder,
+{
+    let (signing_identity, secret_key) = get_test_signing_identity(cipher_suite, b"member").await;
+
+    let client_builder = TestClientBuilder::new_for_test().used_protocol_version(protocol_version);
+
+    let group = custom(client_builder)
+        .signing_identity(signing_identity.clone(), secret_key, cipher_suite)
+        .build()
+        .create_group_with_id(TEST_GROUP.to_vec(), group_extensions())
+        .await
+        .unwrap();
+
+    TestGroup { group }
+}
+
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+pub(crate) async fn test_n_member_group(
+    protocol_version: ProtocolVersion,
+    cipher_suite: CipherSuite,
+    num_members: usize,
+) -> Vec<TestGroup> {
+    let group = test_group(protocol_version, cipher_suite).await;
+
+    let mut groups = vec![group];
+
+    for i in 1..num_members {
+        let (new_group, commit) = groups.get_mut(0).unwrap().join(&format!("name {i}")).await;
+        process_commit(&mut groups, commit, 0).await;
+        groups.push(new_group);
+    }
+
+    groups
+}
+
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+pub(crate) async fn process_commit(groups: &mut [TestGroup], commit: MlsMessage, excluded: u32) {
+    for g in groups
+        .iter_mut()
+        .filter(|g| g.group.current_member_index() != excluded)
+    {
+        g.process_message(commit.clone()).await.unwrap();
+    }
+}
+
+pub(crate) fn get_test_25519_key(key_byte: u8) -> HpkePublicKey {
+    vec![key_byte; 32].into()
+}
+
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+pub(crate) async fn get_test_groups_with_features(
+    n: usize,
+    extensions: ExtensionList,
+    leaf_extensions: ExtensionList,
+) -> Vec<Group<TestClientConfig>> {
+    let mut clients = Vec::new();
+
+    for i in 0..n {
+        let (identity, secret_key) =
+            get_test_signing_identity(TEST_CIPHER_SUITE, format!("member{i}").as_bytes()).await;
+
+        clients.push(
+            TestClientBuilder::new_for_test()
+                .extension_type(999.into())
+                .leaf_node_extensions(leaf_extensions.clone())
+                .signing_identity(identity, secret_key, TEST_CIPHER_SUITE)
+                .build(),
+        );
+    }
+
+    let group = clients[0]
+        .create_group_with_id(b"TEST GROUP".to_vec(), extensions)
+        .await
+        .unwrap();
+
+    let mut groups = vec![group];
+
+    for client in clients.iter().skip(1) {
+        let key_package = client.generate_key_package_message().await.unwrap();
+
+        let commit_output = groups[0]
+            .commit_builder()
+            .add_member(key_package)
+            .unwrap()
+            .build()
+            .await
+            .unwrap();
+
+        groups[0].apply_pending_commit().await.unwrap();
+
+        for group in groups.iter_mut().skip(1) {
+            group
+                .process_incoming_message(commit_output.commit_message.clone())
+                .await
+                .unwrap();
+        }
+
+        groups.push(
+            client
+                .join_group(None, &commit_output.welcome_messages[0])
+                .await
+                .unwrap()
+                .0,
+        );
+    }
+
+    groups
+}
+
+pub fn random_bytes(count: usize) -> Vec<u8> {
+    let mut buf = vec![0; count];
+    rand::thread_rng().fill_bytes(&mut buf);
+    buf
+}
+
+pub(crate) struct GroupWithoutKeySchedule {
+    inner: Group<TestClientConfig>,
+    pub secrets: Option<(TreeKemPrivate, PathSecret)>,
+    pub provisional_public_state: Option<ProvisionalState>,
+}
+
+impl Deref for GroupWithoutKeySchedule {
+    type Target = Group<TestClientConfig>;
+
+    #[cfg_attr(coverage_nightly, coverage(off))]
+    fn deref(&self) -> &Self::Target {
+        &self.inner
+    }
+}
+
+impl DerefMut for GroupWithoutKeySchedule {
+    #[cfg_attr(coverage_nightly, coverage(off))]
+    fn deref_mut(&mut self) -> &mut Self::Target {
+        &mut self.inner
+    }
+}
+
+#[cfg(feature = "rfc_compliant")]
+impl GroupWithoutKeySchedule {
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn new(cs: CipherSuite) -> Self {
+        Self {
+            inner: test_group(TEST_PROTOCOL_VERSION, cs).await.group,
+            secrets: None,
+            provisional_public_state: None,
+        }
+    }
+}
+
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+#[cfg_attr(all(target_arch = "wasm32", mls_build_async), maybe_async::must_be_async(?Send))]
+#[cfg_attr(
+    all(not(target_arch = "wasm32"), mls_build_async),
+    maybe_async::must_be_async
+)]
+impl MessageProcessor for GroupWithoutKeySchedule {
+    type CipherSuiteProvider = <Group<TestClientConfig> as MessageProcessor>::CipherSuiteProvider;
+    type OutputType = <Group<TestClientConfig> as MessageProcessor>::OutputType;
+    type PreSharedKeyStorage = <Group<TestClientConfig> as MessageProcessor>::PreSharedKeyStorage;
+    type IdentityProvider = <Group<TestClientConfig> as MessageProcessor>::IdentityProvider;
+    type MlsRules = <Group<TestClientConfig> as MessageProcessor>::MlsRules;
+
+    fn group_state(&self) -> &GroupState {
+        self.inner.group_state()
+    }
+
+    #[cfg_attr(coverage_nightly, coverage(off))]
+    fn group_state_mut(&mut self) -> &mut GroupState {
+        self.inner.group_state_mut()
+    }
+
+    fn mls_rules(&self) -> Self::MlsRules {
+        self.inner.mls_rules()
+    }
+
+    fn identity_provider(&self) -> Self::IdentityProvider {
+        self.inner.identity_provider()
+    }
+
+    fn cipher_suite_provider(&self) -> &Self::CipherSuiteProvider {
+        self.inner.cipher_suite_provider()
+    }
+
+    fn psk_storage(&self) -> Self::PreSharedKeyStorage {
+        self.inner.psk_storage()
+    }
+
+    fn can_continue_processing(&self, provisional_state: &ProvisionalState) -> bool {
+        self.inner.can_continue_processing(provisional_state)
+    }
+
+    #[cfg(feature = "private_message")]
+    #[cfg_attr(coverage_nightly, coverage(off))]
+    fn min_epoch_available(&self) -> Option<u64> {
+        self.inner.min_epoch_available()
+    }
+
+    async fn apply_update_path(
+        &mut self,
+        sender: LeafIndex,
+        update_path: &ValidatedUpdatePath,
+        provisional_state: &mut ProvisionalState,
+    ) -> Result<Option<(TreeKemPrivate, PathSecret)>, MlsError> {
+        self.inner
+            .apply_update_path(sender, update_path, provisional_state)
+            .await
+    }
+
+    #[cfg(feature = "private_message")]
+    #[cfg_attr(coverage_nightly, coverage(off))]
+    async fn process_ciphertext(
+        &mut self,
+        cipher_text: &PrivateMessage,
+    ) -> Result<EventOrContent<Self::OutputType>, MlsError> {
+        self.inner.process_ciphertext(cipher_text).await
+    }
+
+    #[cfg_attr(coverage_nightly, coverage(off))]
+    async fn verify_plaintext_authentication(
+        &self,
+        message: PublicMessage,
+    ) -> Result<EventOrContent<Self::OutputType>, MlsError> {
+        self.inner.verify_plaintext_authentication(message).await
+    }
+
+    async fn update_key_schedule(
+        &mut self,
+        secrets: Option<(TreeKemPrivate, PathSecret)>,
+        _interim_transcript_hash: InterimTranscriptHash,
+        _confirmation_tag: &ConfirmationTag,
+        provisional_public_state: ProvisionalState,
+    ) -> Result<(), MlsError> {
+        self.provisional_public_state = Some(provisional_public_state);
+        self.secrets = secrets;
+        Ok(())
+    }
+
+    #[cfg(feature = "private_message")]
+    #[cfg_attr(coverage_nightly, coverage(off))]
+    fn self_index(&self) -> Option<LeafIndex> {
+        <Group<TestClientConfig> as MessageProcessor>::self_index(&self.inner)
+    }
+}
diff --git a/src/group/transcript_hash.rs b/src/group/transcript_hash.rs
new file mode 100644
index 0000000..c336dfa
--- /dev/null
+++ b/src/group/transcript_hash.rs
@@ -0,0 +1,293 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// Copyright by contributors to this project.
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+
+use alloc::vec::Vec;
+use core::{
+    fmt::{self, Debug},
+    ops::Deref,
+};
+
+use mls_rs_codec::{MlsDecode, MlsEncode, MlsSize};
+use mls_rs_core::{crypto::CipherSuiteProvider, error::IntoAnyError};
+
+use crate::{
+    client::MlsError,
+    group::{framing::FramedContent, MessageSignature},
+    WireFormat,
+};
+
+use super::{AuthenticatedContent, ConfirmationTag};
+
+#[derive(Clone, PartialEq, Eq, MlsSize, MlsEncode, MlsDecode)]
+#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+pub struct ConfirmedTranscriptHash(
+    #[mls_codec(with = "mls_rs_codec::byte_vec")]
+    #[cfg_attr(feature = "serde", serde(with = "mls_rs_core::vec_serde"))]
+    Vec<u8>,
+);
+
+impl Debug for ConfirmedTranscriptHash {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        mls_rs_core::debug::pretty_bytes(&self.0)
+            .named("ConfirmedTranscriptHash")
+            .fmt(f)
+    }
+}
+
+impl Deref for ConfirmedTranscriptHash {
+    type Target = Vec<u8>;
+
+    fn deref(&self) -> &Self::Target {
+        &self.0
+    }
+}
+
+impl From<Vec<u8>> for ConfirmedTranscriptHash {
+    fn from(value: Vec<u8>) -> Self {
+        Self(value)
+    }
+}
+
+impl ConfirmedTranscriptHash {
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(crate) async fn create<P: CipherSuiteProvider>(
+        cipher_suite_provider: &P,
+        interim_transcript_hash: &InterimTranscriptHash,
+        content: &AuthenticatedContent,
+    ) -> Result<Self, MlsError> {
+        #[derive(Debug, MlsSize, MlsEncode)]
+        struct ConfirmedTranscriptHashInput<'a> {
+            wire_format: WireFormat,
+            content: &'a FramedContent,
+            signature: &'a MessageSignature,
+        }
+
+        let input = ConfirmedTranscriptHashInput {
+            wire_format: content.wire_format,
+            content: &content.content,
+            signature: &content.auth.signature,
+        };
+
+        let hash_input = [
+            interim_transcript_hash.deref(),
+            input.mls_encode_to_vec()?.deref(),
+        ]
+        .concat();
+
+        cipher_suite_provider
+            .hash(&hash_input)
+            .await
+            .map(Into::into)
+            .map_err(|e| MlsError::CryptoProviderError(e.into_any_error()))
+    }
+}
+
+#[derive(Clone, PartialEq, MlsSize, MlsEncode, MlsDecode)]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+pub(crate) struct InterimTranscriptHash(
+    #[mls_codec(with = "mls_rs_codec::byte_vec")]
+    #[cfg_attr(feature = "serde", serde(with = "mls_rs_core::vec_serde"))]
+    Vec<u8>,
+);
+
+impl Debug for InterimTranscriptHash {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        mls_rs_core::debug::pretty_bytes(&self.0)
+            .named("InterimTranscriptHash")
+            .fmt(f)
+    }
+}
+
+impl Deref for InterimTranscriptHash {
+    type Target = Vec<u8>;
+
+    fn deref(&self) -> &Self::Target {
+        &self.0
+    }
+}
+
+impl From<Vec<u8>> for InterimTranscriptHash {
+    fn from(value: Vec<u8>) -> Self {
+        Self(value)
+    }
+}
+
+impl InterimTranscriptHash {
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn create<P: CipherSuiteProvider>(
+        cipher_suite_provider: &P,
+        confirmed: &ConfirmedTranscriptHash,
+        confirmation_tag: &ConfirmationTag,
+    ) -> Result<Self, MlsError> {
+        #[derive(Debug, MlsSize, MlsEncode)]
+        struct InterimTranscriptHashInput<'a> {
+            confirmation_tag: &'a ConfirmationTag,
+        }
+
+        let input = InterimTranscriptHashInput { confirmation_tag }.mls_encode_to_vec()?;
+
+        cipher_suite_provider
+            .hash(&[confirmed.0.deref(), &input].concat())
+            .await
+            .map(Into::into)
+            .map_err(|e| MlsError::CryptoProviderError(e.into_any_error()))
+    }
+}
+
+// Test vectors come from the MLS interop repository and contain a proposal by reference.
+#[cfg(feature = "by_ref_proposal")]
+#[cfg(test)]
+mod tests {
+    use alloc::vec::Vec;
+
+    use mls_rs_codec::MlsDecode;
+
+    use crate::{
+        crypto::test_utils::try_test_cipher_suite_provider,
+        group::{framing::ContentType, message_signature::AuthenticatedContent, transcript_hashes},
+    };
+
+    #[cfg(not(mls_build_async))]
+    use alloc::{boxed::Box, vec};
+
+    #[cfg(not(mls_build_async))]
+    use crate::{
+        crypto::test_utils::test_cipher_suite_provider,
+        group::{
+            confirmation_tag::ConfirmationTag,
+            framing::Content,
+            proposal::{Proposal, ProposalOrRef, RemoveProposal},
+            test_utils::get_test_group_context,
+            Commit, LeafIndex, Sender,
+        },
+        mls_rs_codec::MlsEncode,
+        CipherSuite, CipherSuiteProvider, WireFormat,
+    };
+
+    #[cfg(not(mls_build_async))]
+    use super::{ConfirmedTranscriptHash, InterimTranscriptHash};
+
+    #[derive(serde::Serialize, serde::Deserialize, Debug, Default, Clone)]
+    struct TestCase {
+        pub cipher_suite: u16,
+
+        #[serde(with = "hex::serde")]
+        pub confirmation_key: Vec<u8>,
+        #[serde(with = "hex::serde")]
+        pub authenticated_content: Vec<u8>,
+        #[serde(with = "hex::serde")]
+        pub interim_transcript_hash_before: Vec<u8>,
+
+        #[serde(with = "hex::serde")]
+        pub confirmed_transcript_hash_after: Vec<u8>,
+        #[serde(with = "hex::serde")]
+        pub interim_transcript_hash_after: Vec<u8>,
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn transcript_hash() {
+        let test_cases: Vec<TestCase> =
+            load_test_case_json!(interop_transcript_hashes, generate_test_vector());
+
+        for test_case in test_cases.into_iter() {
+            let Some(cs) = try_test_cipher_suite_provider(test_case.cipher_suite) else {
+                continue;
+            };
+
+            let auth_content =
+                AuthenticatedContent::mls_decode(&mut &*test_case.authenticated_content).unwrap();
+
+            assert!(auth_content.content.content_type() == ContentType::Commit);
+
+            let conf_key = &test_case.confirmation_key;
+            let conf_hash_after = test_case.confirmed_transcript_hash_after.into();
+            let conf_tag = auth_content.auth.confirmation_tag.clone().unwrap();
+
+            let matches = conf_tag
+                .matches(conf_key, &conf_hash_after, &cs)
+                .await
+                .unwrap();
+
+            assert!(matches);
+
+            let (expected_interim, expected_conf) = transcript_hashes(
+                &cs,
+                &test_case.interim_transcript_hash_before.into(),
+                &auth_content,
+            )
+            .await
+            .unwrap();
+
+            assert_eq!(*expected_interim, test_case.interim_transcript_hash_after);
+            assert_eq!(expected_conf, conf_hash_after);
+        }
+    }
+
+    #[cfg(not(mls_build_async))]
+    #[cfg_attr(coverage_nightly, coverage(off))]
+    fn generate_test_vector() -> Vec<TestCase> {
+        CipherSuite::all().fold(vec![], |mut test_cases, cs| {
+            let cs = test_cipher_suite_provider(cs);
+
+            let context = get_test_group_context(0x3456, cs.cipher_suite());
+
+            let proposal = Proposal::Remove(RemoveProposal {
+                to_remove: LeafIndex(1),
+            });
+
+            let proposal = ProposalOrRef::Proposal(Box::new(proposal));
+
+            let commit = Commit {
+                proposals: vec![proposal],
+                path: None,
+            };
+
+            let signer = cs.signature_key_generate().unwrap().0;
+
+            let mut auth_content = AuthenticatedContent::new_signed(
+                &cs,
+                &context,
+                Sender::Member(0),
+                Content::Commit(alloc::boxed::Box::new(commit)),
+                &signer,
+                WireFormat::PublicMessage,
+                vec![],
+            )
+            .unwrap();
+
+            let interim_hash_before = cs.random_bytes_vec(cs.kdf_extract_size()).unwrap().into();
+
+            let conf_hash_after =
+                ConfirmedTranscriptHash::create(&cs, &interim_hash_before, &auth_content).unwrap();
+
+            let conf_key = cs.random_bytes_vec(cs.kdf_extract_size()).unwrap();
+            let conf_tag = ConfirmationTag::create(&conf_key, &conf_hash_after, &cs).unwrap();
+
+            let interim_hash_after =
+                InterimTranscriptHash::create(&cs, &conf_hash_after, &conf_tag).unwrap();
+
+            auth_content.auth.confirmation_tag = Some(conf_tag);
+
+            let test_case = TestCase {
+                cipher_suite: cs.cipher_suite().into(),
+
+                confirmation_key: conf_key,
+                authenticated_content: auth_content.mls_encode_to_vec().unwrap(),
+                interim_transcript_hash_before: interim_hash_before.0,
+
+                confirmed_transcript_hash_after: conf_hash_after.0,
+                interim_transcript_hash_after: interim_hash_after.0,
+            };
+
+            test_cases.push(test_case);
+            test_cases
+        })
+    }
+
+    #[cfg(mls_build_async)]
+    fn generate_test_vector() -> Vec<TestCase> {
+        panic!("Tests cannot be generated in async mode");
+    }
+}
diff --git a/src/group/util.rs b/src/group/util.rs
new file mode 100644
index 0000000..dadfafa
--- /dev/null
+++ b/src/group/util.rs
@@ -0,0 +1,202 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// Copyright by contributors to this project.
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+
+use mls_rs_core::{
+    error::IntoAnyError, identity::IdentityProvider, key_package::KeyPackageStorage,
+};
+
+use crate::{
+    cipher_suite::CipherSuite,
+    client::MlsError,
+    extension::RatchetTreeExt,
+    key_package::KeyPackageGeneration,
+    protocol_version::ProtocolVersion,
+    signer::Signable,
+    tree_kem::{node::LeafIndex, tree_validator::TreeValidator, TreeKemPublic},
+    CipherSuiteProvider, CryptoProvider,
+};
+
+#[cfg(feature = "by_ref_proposal")]
+use crate::extension::ExternalSendersExt;
+
+use super::{
+    framing::Sender, message_signature::AuthenticatedContent,
+    transcript_hash::InterimTranscriptHash, ConfirmedTranscriptHash, EncryptedGroupSecrets,
+    ExportedTree, GroupInfo, GroupState,
+};
+
+use super::message_processor::ProvisionalState;
+
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+pub(crate) async fn validate_group_info_common<C: CipherSuiteProvider>(
+    msg_version: ProtocolVersion,
+    group_info: &GroupInfo,
+    tree: &TreeKemPublic,
+    cs: &C,
+) -> Result<(), MlsError> {
+    if msg_version != group_info.group_context.protocol_version {
+        return Err(MlsError::ProtocolVersionMismatch);
+    }
+
+    if group_info.group_context.cipher_suite != cs.cipher_suite() {
+        return Err(MlsError::CipherSuiteMismatch);
+    }
+
+    let sender_leaf = &tree.get_leaf_node(group_info.signer)?;
+
+    group_info
+        .verify(cs, &sender_leaf.signing_identity.signature_key, &())
+        .await?;
+
+    Ok(())
+}
+
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+pub(crate) async fn validate_group_info_member<C: CipherSuiteProvider>(
+    self_state: &GroupState,
+    msg_version: ProtocolVersion,
+    group_info: &GroupInfo,
+    cs: &C,
+) -> Result<(), MlsError> {
+    validate_group_info_common(msg_version, group_info, &self_state.public_tree, cs).await?;
+
+    let self_tree = ExportedTree::new_borrowed(&self_state.public_tree.nodes);
+
+    if let Some(tree) = group_info.extensions.get_as::<RatchetTreeExt>()? {
+        (tree.tree_data == self_tree)
+            .then_some(())
+            .ok_or(MlsError::InvalidGroupInfo)?;
+    }
+
+    (group_info.group_context == self_state.context
+        && group_info.confirmation_tag == self_state.confirmation_tag)
+        .then_some(())
+        .ok_or(MlsError::InvalidGroupInfo)?;
+
+    Ok(())
+}
+
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+pub(crate) async fn validate_group_info_joiner<C, I>(
+    msg_version: ProtocolVersion,
+    group_info: &GroupInfo,
+    tree: Option<ExportedTree<'_>>,
+    id_provider: &I,
+    cs: &C,
+) -> Result<TreeKemPublic, MlsError>
+where
+    C: CipherSuiteProvider,
+    I: IdentityProvider,
+{
+    let tree = match group_info.extensions.get_as::<RatchetTreeExt>()? {
+        Some(ext) => ext.tree_data,
+        None => tree.ok_or(MlsError::RatchetTreeNotFound)?,
+    };
+
+    let context = &group_info.group_context;
+
+    let mut tree =
+        TreeKemPublic::import_node_data(tree.into(), id_provider, &context.extensions).await?;
+
+    // Verify the integrity of the ratchet tree
+    TreeValidator::new(cs, context, id_provider)
+        .validate(&mut tree)
+        .await?;
+
+    #[cfg(feature = "by_ref_proposal")]
+    if let Some(ext_senders) = context.extensions.get_as::<ExternalSendersExt>()? {
+        // TODO do joiners verify group against current time??
+        ext_senders
+            .verify_all(id_provider, None, &context.extensions)
+            .await
+            .map_err(|e| MlsError::IdentityProviderError(e.into_any_error()))?;
+    }
+
+    validate_group_info_common(msg_version, group_info, &tree, cs).await?;
+
+    Ok(tree)
+}
+
+pub(crate) fn commit_sender(
+    sender: &Sender,
+    provisional_state: &ProvisionalState,
+) -> Result<LeafIndex, MlsError> {
+    match sender {
+        Sender::Member(index) => Ok(LeafIndex(*index)),
+        #[cfg(feature = "by_ref_proposal")]
+        Sender::External(_) => Err(MlsError::ExternalSenderCannotCommit),
+        #[cfg(feature = "by_ref_proposal")]
+        Sender::NewMemberProposal => Err(MlsError::ExpectedAddProposalForNewMemberProposal),
+        Sender::NewMemberCommit => provisional_state
+            .external_init_index
+            .ok_or(MlsError::ExternalCommitMissingExternalInit),
+    }
+}
+
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+pub(super) async fn transcript_hashes<P: CipherSuiteProvider>(
+    cipher_suite_provider: &P,
+    prev_interim_transcript_hash: &InterimTranscriptHash,
+    content: &AuthenticatedContent,
+) -> Result<(InterimTranscriptHash, ConfirmedTranscriptHash), MlsError> {
+    let confirmed_transcript_hash = ConfirmedTranscriptHash::create(
+        cipher_suite_provider,
+        prev_interim_transcript_hash,
+        content,
+    )
+    .await?;
+
+    let confirmation_tag = content
+        .auth
+        .confirmation_tag
+        .as_ref()
+        .ok_or(MlsError::InvalidConfirmationTag)?;
+
+    let interim_transcript_hash = InterimTranscriptHash::create(
+        cipher_suite_provider,
+        &confirmed_transcript_hash,
+        confirmation_tag,
+    )
+    .await?;
+
+    Ok((interim_transcript_hash, confirmed_transcript_hash))
+}
+
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+pub(crate) async fn find_key_package_generation<'a, K: KeyPackageStorage>(
+    key_package_repo: &K,
+    secrets: &'a [EncryptedGroupSecrets],
+) -> Result<(&'a EncryptedGroupSecrets, KeyPackageGeneration), MlsError> {
+    for secret in secrets {
+        if let Some(val) = key_package_repo
+            .get(&secret.new_member)
+            .await
+            .map_err(|e| MlsError::KeyPackageRepoError(e.into_any_error()))
+            .and_then(|maybe_data| {
+                if let Some(data) = maybe_data {
+                    KeyPackageGeneration::from_storage(secret.new_member.to_vec(), data)
+                        .map(|kpg| Some((secret, kpg)))
+                } else {
+                    Ok::<_, MlsError>(None)
+                }
+            })?
+        {
+            return Ok(val);
+        }
+    }
+
+    Err(MlsError::WelcomeKeyPackageNotFound)
+}
+
+pub(crate) fn cipher_suite_provider<P>(
+    crypto: P,
+    cipher_suite: CipherSuite,
+) -> Result<P::CipherSuiteProvider, MlsError>
+where
+    P: CryptoProvider,
+{
+    crypto
+        .cipher_suite_provider(cipher_suite)
+        .ok_or(MlsError::UnsupportedCipherSuite(cipher_suite))
+}