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/client.rs b/src/client.rs
new file mode 100644
index 0000000..a7031bb
--- /dev/null
+++ b/src/client.rs
@@ -0,0 +1,1049 @@
+// 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::cipher_suite::CipherSuite;
+use crate::client_builder::{recreate_config, BaseConfig, ClientBuilder, MakeConfig};
+use crate::client_config::ClientConfig;
+use crate::group::framing::MlsMessage;
+
+#[cfg(feature = "by_ref_proposal")]
+use crate::group::{
+    framing::{Content, MlsMessagePayload, PublicMessage, Sender, WireFormat},
+    message_signature::AuthenticatedContent,
+    proposal::{AddProposal, Proposal},
+};
+use crate::group::{snapshot::Snapshot, ExportedTree, Group, NewMemberInfo};
+use crate::identity::SigningIdentity;
+use crate::key_package::{KeyPackageGeneration, KeyPackageGenerator};
+use crate::protocol_version::ProtocolVersion;
+use crate::tree_kem::node::NodeIndex;
+use alloc::vec::Vec;
+use mls_rs_codec::MlsDecode;
+use mls_rs_core::crypto::{CryptoProvider, SignatureSecretKey};
+use mls_rs_core::error::{AnyError, IntoAnyError};
+use mls_rs_core::extension::{ExtensionError, ExtensionList, ExtensionType};
+use mls_rs_core::group::{GroupStateStorage, ProposalType};
+use mls_rs_core::identity::CredentialType;
+use mls_rs_core::key_package::KeyPackageStorage;
+
+use crate::group::external_commit::ExternalCommitBuilder;
+
+#[cfg(feature = "by_ref_proposal")]
+use alloc::boxed::Box;
+
+#[derive(Debug)]
+#[cfg_attr(feature = "std", derive(thiserror::Error))]
+#[cfg_attr(all(feature = "ffi", not(test)), safer_ffi_gen::enum_to_error_code)]
+#[non_exhaustive]
+pub enum MlsError {
+    #[cfg_attr(feature = "std", error(transparent))]
+    IdentityProviderError(AnyError),
+    #[cfg_attr(feature = "std", error(transparent))]
+    CryptoProviderError(AnyError),
+    #[cfg_attr(feature = "std", error(transparent))]
+    KeyPackageRepoError(AnyError),
+    #[cfg_attr(feature = "std", error(transparent))]
+    GroupStorageError(AnyError),
+    #[cfg_attr(feature = "std", error(transparent))]
+    PskStoreError(AnyError),
+    #[cfg_attr(feature = "std", error(transparent))]
+    MlsRulesError(AnyError),
+    #[cfg_attr(feature = "std", error(transparent))]
+    SerializationError(AnyError),
+    #[cfg_attr(feature = "std", error(transparent))]
+    ExtensionError(AnyError),
+    #[cfg_attr(feature = "std", error("Cipher suite does not match"))]
+    CipherSuiteMismatch,
+    #[cfg_attr(feature = "std", error("Invalid commit, missing required path"))]
+    CommitMissingPath,
+    #[cfg_attr(feature = "std", error("plaintext message for incorrect epoch"))]
+    InvalidEpoch,
+    #[cfg_attr(feature = "std", error("invalid signature found"))]
+    InvalidSignature,
+    #[cfg_attr(feature = "std", error("invalid confirmation tag"))]
+    InvalidConfirmationTag,
+    #[cfg_attr(feature = "std", error("invalid membership tag"))]
+    InvalidMembershipTag,
+    #[cfg_attr(feature = "std", error("corrupt private key, missing required values"))]
+    InvalidTreeKemPrivateKey,
+    #[cfg_attr(feature = "std", error("key package not found, unable to process"))]
+    WelcomeKeyPackageNotFound,
+    #[cfg_attr(feature = "std", error("leaf not found in tree for index {0}"))]
+    LeafNotFound(u32),
+    #[cfg_attr(feature = "std", error("message from self can't be processed"))]
+    CantProcessMessageFromSelf,
+    #[cfg_attr(
+        feature = "std",
+        error("pending proposals found, commit required before application messages can be sent")
+    )]
+    CommitRequired,
+    #[cfg_attr(
+        feature = "std",
+        error("ratchet tree not provided or discovered in GroupInfo")
+    )]
+    RatchetTreeNotFound,
+    #[cfg_attr(feature = "std", error("External sender cannot commit"))]
+    ExternalSenderCannotCommit,
+    #[cfg_attr(feature = "std", error("Unsupported protocol version {0:?}"))]
+    UnsupportedProtocolVersion(ProtocolVersion),
+    #[cfg_attr(feature = "std", error("Protocol version mismatch"))]
+    ProtocolVersionMismatch,
+    #[cfg_attr(feature = "std", error("Unsupported cipher suite {0:?}"))]
+    UnsupportedCipherSuite(CipherSuite),
+    #[cfg_attr(feature = "std", error("Signing key of external sender is unknown"))]
+    UnknownSigningIdentityForExternalSender,
+    #[cfg_attr(
+        feature = "std",
+        error("External proposals are disabled for this group")
+    )]
+    ExternalProposalsDisabled,
+    #[cfg_attr(
+        feature = "std",
+        error("Signing identity is not allowed to externally propose")
+    )]
+    InvalidExternalSigningIdentity,
+    #[cfg_attr(feature = "std", error("Missing ExternalPub extension"))]
+    MissingExternalPubExtension,
+    #[cfg_attr(feature = "std", error("Epoch not found"))]
+    EpochNotFound,
+    #[cfg_attr(feature = "std", error("Unencrypted application message"))]
+    UnencryptedApplicationMessage,
+    #[cfg_attr(
+        feature = "std",
+        error("NewMemberCommit sender type can only be used to send Commit content")
+    )]
+    ExpectedCommitForNewMemberCommit,
+    #[cfg_attr(
+        feature = "std",
+        error("NewMemberProposal sender type can only be used to send add proposals")
+    )]
+    ExpectedAddProposalForNewMemberProposal,
+    #[cfg_attr(
+        feature = "std",
+        error("External commit missing ExternalInit proposal")
+    )]
+    ExternalCommitMissingExternalInit,
+    #[cfg_attr(
+        feature = "std",
+        error(
+            "A ReIinit has been applied. The next action must be creating or receiving a welcome."
+        )
+    )]
+    GroupUsedAfterReInit,
+    #[cfg_attr(feature = "std", error("Pending ReIinit not found."))]
+    PendingReInitNotFound,
+    #[cfg_attr(
+        feature = "std",
+        error("The extensions in the welcome message and in the reinit do not match.")
+    )]
+    ReInitExtensionsMismatch,
+    #[cfg_attr(feature = "std", error("signer not found for given identity"))]
+    SignerNotFound,
+    #[cfg_attr(feature = "std", error("commit already pending"))]
+    ExistingPendingCommit,
+    #[cfg_attr(feature = "std", error("pending commit not found"))]
+    PendingCommitNotFound,
+    #[cfg_attr(feature = "std", error("unexpected message type for action"))]
+    UnexpectedMessageType,
+    #[cfg_attr(
+        feature = "std",
+        error("membership tag on MlsPlaintext for non-member sender")
+    )]
+    MembershipTagForNonMember,
+    #[cfg_attr(feature = "std", error("No member found for given identity id."))]
+    MemberNotFound,
+    #[cfg_attr(feature = "std", error("group not found"))]
+    GroupNotFound,
+    #[cfg_attr(feature = "std", error("unexpected PSK ID"))]
+    UnexpectedPskId,
+    #[cfg_attr(feature = "std", error("invalid sender for content type"))]
+    InvalidSender,
+    #[cfg_attr(feature = "std", error("GroupID mismatch"))]
+    GroupIdMismatch,
+    #[cfg_attr(feature = "std", error("storage retention can not be zero"))]
+    NonZeroRetentionRequired,
+    #[cfg_attr(feature = "std", error("Too many PSK IDs to compute PSK secret"))]
+    TooManyPskIds,
+    #[cfg_attr(feature = "std", error("Missing required Psk"))]
+    MissingRequiredPsk,
+    #[cfg_attr(feature = "std", error("Old group state not found"))]
+    OldGroupStateNotFound,
+    #[cfg_attr(feature = "std", error("leaf secret already consumed"))]
+    InvalidLeafConsumption,
+    #[cfg_attr(feature = "std", error("key not available, invalid generation {0}"))]
+    KeyMissing(u32),
+    #[cfg_attr(
+        feature = "std",
+        error("requested generation {0} is too far ahead of current generation")
+    )]
+    InvalidFutureGeneration(u32),
+    #[cfg_attr(feature = "std", error("leaf node has no children"))]
+    LeafNodeNoChildren,
+    #[cfg_attr(feature = "std", error("root node has no parent"))]
+    LeafNodeNoParent,
+    #[cfg_attr(feature = "std", error("index out of range"))]
+    InvalidTreeIndex,
+    #[cfg_attr(feature = "std", error("time overflow"))]
+    TimeOverflow,
+    #[cfg_attr(feature = "std", error("invalid leaf_node_source"))]
+    InvalidLeafNodeSource,
+    #[cfg_attr(feature = "std", error("key package has expired or is not valid yet"))]
+    InvalidLifetime,
+    #[cfg_attr(feature = "std", error("required extension not found"))]
+    RequiredExtensionNotFound(ExtensionType),
+    #[cfg_attr(feature = "std", error("required proposal not found"))]
+    RequiredProposalNotFound(ProposalType),
+    #[cfg_attr(feature = "std", error("required credential not found"))]
+    RequiredCredentialNotFound(CredentialType),
+    #[cfg_attr(feature = "std", error("capabilities must describe extensions used"))]
+    ExtensionNotInCapabilities(ExtensionType),
+    #[cfg_attr(feature = "std", error("expected non-blank node"))]
+    ExpectedNode,
+    #[cfg_attr(feature = "std", error("node index is out of bounds {0}"))]
+    InvalidNodeIndex(NodeIndex),
+    #[cfg_attr(feature = "std", error("unexpected empty node found"))]
+    UnexpectedEmptyNode,
+    #[cfg_attr(
+        feature = "std",
+        error("duplicate signature key, hpke key or identity found at index {0}")
+    )]
+    DuplicateLeafData(u32),
+    #[cfg_attr(
+        feature = "std",
+        error("In-use credential type not supported by new leaf at index")
+    )]
+    InUseCredentialTypeUnsupportedByNewLeaf,
+    #[cfg_attr(
+        feature = "std",
+        error("Not all members support the credential type used by new leaf")
+    )]
+    CredentialTypeOfNewLeafIsUnsupported,
+    #[cfg_attr(
+        feature = "std",
+        error("the length of the update path is different than the length of the direct path")
+    )]
+    WrongPathLen,
+    #[cfg_attr(
+        feature = "std",
+        error("same HPKE leaf key before and after applying the update path for leaf {0}")
+    )]
+    SameHpkeKey(u32),
+    #[cfg_attr(feature = "std", error("init key is not valid for cipher suite"))]
+    InvalidInitKey,
+    #[cfg_attr(
+        feature = "std",
+        error("init key can not be equal to leaf node public key")
+    )]
+    InitLeafKeyEquality,
+    #[cfg_attr(feature = "std", error("different identity in update for leaf {0}"))]
+    DifferentIdentityInUpdate(u32),
+    #[cfg_attr(feature = "std", error("update path pub key mismatch"))]
+    PubKeyMismatch,
+    #[cfg_attr(feature = "std", error("tree hash mismatch"))]
+    TreeHashMismatch,
+    #[cfg_attr(feature = "std", error("bad update: no suitable secret key"))]
+    UpdateErrorNoSecretKey,
+    #[cfg_attr(feature = "std", error("invalid lca, not found on direct path"))]
+    LcaNotFoundInDirectPath,
+    #[cfg_attr(feature = "std", error("update path parent hash mismatch"))]
+    ParentHashMismatch,
+    #[cfg_attr(feature = "std", error("unexpected pattern of unmerged leaves"))]
+    UnmergedLeavesMismatch,
+    #[cfg_attr(feature = "std", error("empty tree"))]
+    UnexpectedEmptyTree,
+    #[cfg_attr(feature = "std", error("trailing blanks"))]
+    UnexpectedTrailingBlanks,
+    // Proposal Rules errors
+    #[cfg_attr(
+        feature = "std",
+        error("Commiter must not include any update proposals generated by the commiter")
+    )]
+    InvalidCommitSelfUpdate,
+    #[cfg_attr(feature = "std", error("A PreSharedKey proposal must have a PSK of type External or type Resumption and usage Application"))]
+    InvalidTypeOrUsageInPreSharedKeyProposal,
+    #[cfg_attr(feature = "std", error("psk nonce length does not match cipher suite"))]
+    InvalidPskNonceLength,
+    #[cfg_attr(
+        feature = "std",
+        error("ReInit proposal protocol version is less than the version of the original group")
+    )]
+    InvalidProtocolVersionInReInit,
+    #[cfg_attr(feature = "std", error("More than one proposal applying to leaf: {0}"))]
+    MoreThanOneProposalForLeaf(u32),
+    #[cfg_attr(
+        feature = "std",
+        error("More than one GroupContextExtensions proposal")
+    )]
+    MoreThanOneGroupContextExtensionsProposal,
+    #[cfg_attr(feature = "std", error("Invalid proposal type for sender"))]
+    InvalidProposalTypeForSender,
+    #[cfg_attr(
+        feature = "std",
+        error("External commit must have exactly one ExternalInit proposal")
+    )]
+    ExternalCommitMustHaveExactlyOneExternalInit,
+    #[cfg_attr(feature = "std", error("External commit must have a new leaf"))]
+    ExternalCommitMustHaveNewLeaf,
+    #[cfg_attr(
+        feature = "std",
+        error("External commit contains removal of other identity")
+    )]
+    ExternalCommitRemovesOtherIdentity,
+    #[cfg_attr(
+        feature = "std",
+        error("External commit contains more than one Remove proposal")
+    )]
+    ExternalCommitWithMoreThanOneRemove,
+    #[cfg_attr(feature = "std", error("Duplicate PSK IDs"))]
+    DuplicatePskIds,
+    #[cfg_attr(
+        feature = "std",
+        error("Invalid proposal type {0:?} in external commit")
+    )]
+    InvalidProposalTypeInExternalCommit(ProposalType),
+    #[cfg_attr(feature = "std", error("Committer can not remove themselves"))]
+    CommitterSelfRemoval,
+    #[cfg_attr(
+        feature = "std",
+        error("Only members can commit proposals by reference")
+    )]
+    OnlyMembersCanCommitProposalsByRef,
+    #[cfg_attr(feature = "std", error("Other proposal with ReInit"))]
+    OtherProposalWithReInit,
+    #[cfg_attr(feature = "std", error("Unsupported group extension {0:?}"))]
+    UnsupportedGroupExtension(ExtensionType),
+    #[cfg_attr(feature = "std", error("Unsupported custom proposal type {0:?}"))]
+    UnsupportedCustomProposal(ProposalType),
+    #[cfg_attr(feature = "std", error("by-ref proposal not found"))]
+    ProposalNotFound,
+    #[cfg_attr(
+        feature = "std",
+        error("Removing non-existing member (or removing a member twice)")
+    )]
+    RemovingNonExistingMember,
+    #[cfg_attr(feature = "std", error("Updated identity not a valid successor"))]
+    InvalidSuccessor,
+    #[cfg_attr(
+        feature = "std",
+        error("Updating non-existing member (or updating a member twice)")
+    )]
+    UpdatingNonExistingMember,
+    #[cfg_attr(feature = "std", error("Failed generating next path secret"))]
+    FailedGeneratingPathSecret,
+    #[cfg_attr(feature = "std", error("Invalid group info"))]
+    InvalidGroupInfo,
+    #[cfg_attr(feature = "std", error("Invalid welcome message"))]
+    InvalidWelcomeMessage,
+}
+
+impl IntoAnyError for MlsError {
+    #[cfg(feature = "std")]
+    fn into_dyn_error(self) -> Result<Box<dyn std::error::Error + Send + Sync>, Self> {
+        Ok(self.into())
+    }
+}
+
+impl From<mls_rs_codec::Error> for MlsError {
+    #[inline]
+    fn from(e: mls_rs_codec::Error) -> Self {
+        MlsError::SerializationError(e.into_any_error())
+    }
+}
+
+impl From<ExtensionError> for MlsError {
+    #[inline]
+    fn from(e: ExtensionError) -> Self {
+        MlsError::ExtensionError(e.into_any_error())
+    }
+}
+
+/// MLS client used to create key packages and manage groups.
+///
+/// [`Client::builder`] can be used to instantiate it.
+///
+/// Clients are able to support multiple protocol versions, ciphersuites
+/// and underlying identities used to join groups and generate key packages.
+/// Applications may decide to create one or many clients depending on their
+/// specific needs.
+#[cfg_attr(all(feature = "ffi", not(test)), safer_ffi_gen::ffi_type(opaque))]
+#[derive(Clone, Debug)]
+pub struct Client<C> {
+    pub(crate) config: C,
+    pub(crate) signing_identity: Option<(SigningIdentity, CipherSuite)>,
+    pub(crate) signer: Option<SignatureSecretKey>,
+    pub(crate) version: ProtocolVersion,
+}
+
+impl Client<()> {
+    /// Returns a [`ClientBuilder`]
+    /// used to configure client preferences and providers.
+    pub fn builder() -> ClientBuilder<BaseConfig> {
+        ClientBuilder::new()
+    }
+}
+
+#[cfg_attr(all(feature = "ffi", not(test)), safer_ffi_gen::safer_ffi_gen)]
+impl<C> Client<C>
+where
+    C: ClientConfig + Clone,
+{
+    pub(crate) fn new(
+        config: C,
+        signer: Option<SignatureSecretKey>,
+        signing_identity: Option<(SigningIdentity, CipherSuite)>,
+        version: ProtocolVersion,
+    ) -> Self {
+        Client {
+            config,
+            signer,
+            signing_identity,
+            version,
+        }
+    }
+
+    #[cfg_attr(all(feature = "ffi", not(test)), safer_ffi_gen::safer_ffi_gen_ignore)]
+    pub fn to_builder(&self) -> ClientBuilder<MakeConfig<C>> {
+        ClientBuilder::from_config(recreate_config(
+            self.config.clone(),
+            self.signer.clone(),
+            self.signing_identity.clone(),
+            self.version,
+        ))
+    }
+
+    /// Creates a new key package message that can be used to to add this
+    /// client to a [Group](crate::group::Group). Each call to this function
+    /// will produce a unique value that is signed by `signing_identity`.
+    ///
+    /// The secret keys for the resulting key package message will be stored in
+    /// the [KeyPackageStorage](crate::KeyPackageStorage)
+    /// that was used to configure the client and will
+    /// automatically be erased when this key package is used to
+    /// [join a group](Client::join_group).
+    ///
+    /// # Warning
+    ///
+    /// A key package message may only be used once.
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn generate_key_package_message(&self) -> Result<MlsMessage, MlsError> {
+        Ok(self.generate_key_package().await?.key_package_message())
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn generate_key_package(&self) -> Result<KeyPackageGeneration, MlsError> {
+        let (signing_identity, cipher_suite) = self.signing_identity()?;
+
+        let cipher_suite_provider = self
+            .config
+            .crypto_provider()
+            .cipher_suite_provider(cipher_suite)
+            .ok_or(MlsError::UnsupportedCipherSuite(cipher_suite))?;
+
+        let key_package_generator = KeyPackageGenerator {
+            protocol_version: self.version,
+            cipher_suite_provider: &cipher_suite_provider,
+            signing_key: self.signer()?,
+            signing_identity,
+            identity_provider: &self.config.identity_provider(),
+        };
+
+        let key_pkg_gen = key_package_generator
+            .generate(
+                self.config.lifetime(),
+                self.config.capabilities(),
+                self.config.key_package_extensions(),
+                self.config.leaf_node_extensions(),
+            )
+            .await?;
+
+        let (id, key_package_data) = key_pkg_gen.to_storage()?;
+
+        self.config
+            .key_package_repo()
+            .insert(id, key_package_data)
+            .await
+            .map_err(|e| MlsError::KeyPackageRepoError(e.into_any_error()))?;
+
+        Ok(key_pkg_gen)
+    }
+
+    /// Create a group with a specific group_id.
+    ///
+    /// This function behaves the same way as
+    /// [create_group](Client::create_group) except that it
+    /// specifies a specific unique group identifier to be used.
+    ///
+    /// # Warning
+    ///
+    /// It is recommended to use [create_group](Client::create_group)
+    /// instead of this function because it guarantees that group_id values
+    /// are globally unique.
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn create_group_with_id(
+        &self,
+        group_id: Vec<u8>,
+        group_context_extensions: ExtensionList,
+    ) -> Result<Group<C>, MlsError> {
+        let (signing_identity, cipher_suite) = self.signing_identity()?;
+
+        Group::new(
+            self.config.clone(),
+            Some(group_id),
+            cipher_suite,
+            self.version,
+            signing_identity.clone(),
+            group_context_extensions,
+            self.signer()?.clone(),
+        )
+        .await
+    }
+
+    /// Create a MLS group.
+    ///
+    /// The `cipher_suite` provided must be supported by the
+    /// [CipherSuiteProvider](crate::CipherSuiteProvider)
+    /// that was used to build the client.
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn create_group(
+        &self,
+        group_context_extensions: ExtensionList,
+    ) -> Result<Group<C>, MlsError> {
+        let (signing_identity, cipher_suite) = self.signing_identity()?;
+
+        Group::new(
+            self.config.clone(),
+            None,
+            cipher_suite,
+            self.version,
+            signing_identity.clone(),
+            group_context_extensions,
+            self.signer()?.clone(),
+        )
+        .await
+    }
+
+    /// Join a MLS group via a welcome message created by a
+    /// [Commit](crate::group::CommitOutput).
+    ///
+    /// `tree_data` is required to be provided out of band if the client that
+    /// created `welcome_message` did not use the `ratchet_tree_extension`
+    /// according to [`MlsRules::commit_options`](`crate::MlsRules::commit_options`).
+    /// at the time the welcome message was created. `tree_data` can
+    /// be exported from a group using the
+    /// [export tree function](crate::group::Group::export_tree).
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn join_group(
+        &self,
+        tree_data: Option<ExportedTree<'_>>,
+        welcome_message: &MlsMessage,
+    ) -> Result<(Group<C>, NewMemberInfo), MlsError> {
+        Group::join(
+            welcome_message,
+            tree_data,
+            self.config.clone(),
+            self.signer()?.clone(),
+        )
+        .await
+    }
+
+    /// 0-RTT add to an existing [group](crate::group::Group)
+    ///
+    /// External commits allow for immediate entry into a
+    /// [group](crate::group::Group), even if all of the group members
+    /// are currently offline and unable to process messages. Sending an
+    /// external commit is only allowed for groups that have provided
+    /// a public `group_info_message` containing an
+    /// [ExternalPubExt](crate::extension::ExternalPubExt), which can be
+    /// generated by an existing group member using the
+    /// [group_info_message](crate::group::Group::group_info_message)
+    /// function.
+    ///
+    /// `tree_data` may be provided following the same rules as [Client::join_group]
+    ///
+    /// If PSKs are provided in `external_psks`, the
+    /// [PreSharedKeyStorage](crate::PreSharedKeyStorage)
+    /// used to configure the client will be searched to resolve their values.
+    ///
+    /// `to_remove` may be used to remove an existing member provided that the
+    /// identity of the existing group member at that [index](crate::group::Member::index)
+    /// is a [valid successor](crate::IdentityProvider::valid_successor)
+    /// of `signing_identity` as defined by the
+    /// [IdentityProvider](crate::IdentityProvider) that this client
+    /// was configured with.
+    ///
+    /// # Warning
+    ///
+    /// Only one external commit can be performed against a given group info.
+    /// There may also be security trade-offs to this approach.
+    ///
+    // TODO: Add a comment about forward secrecy and a pointer to the future
+    // book chapter on this topic
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn commit_external(
+        &self,
+        group_info_msg: MlsMessage,
+    ) -> Result<(Group<C>, MlsMessage), MlsError> {
+        ExternalCommitBuilder::new(
+            self.signer()?.clone(),
+            self.signing_identity()?.0.clone(),
+            self.config.clone(),
+        )
+        .build(group_info_msg)
+        .await
+    }
+
+    pub fn external_commit_builder(&self) -> Result<ExternalCommitBuilder<C>, MlsError> {
+        Ok(ExternalCommitBuilder::new(
+            self.signer()?.clone(),
+            self.signing_identity()?.0.clone(),
+            self.config.clone(),
+        ))
+    }
+
+    /// Load an existing group state into this client using the
+    /// [GroupStateStorage](crate::GroupStateStorage) that
+    /// this client was configured to use.
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    #[inline(never)]
+    pub async fn load_group(&self, group_id: &[u8]) -> Result<Group<C>, MlsError> {
+        let snapshot = self
+            .config
+            .group_state_storage()
+            .state(group_id)
+            .await
+            .map_err(|e| MlsError::GroupStorageError(e.into_any_error()))?
+            .ok_or(MlsError::GroupNotFound)?;
+
+        let snapshot = Snapshot::mls_decode(&mut &*snapshot)?;
+
+        Group::from_snapshot(self.config.clone(), snapshot).await
+    }
+
+    /// Request to join an existing [group](crate::group::Group).
+    ///
+    /// An existing group member will need to perform a
+    /// [commit](crate::Group::commit) to complete the add and the resulting
+    /// welcome message can be used by [join_group](Client::join_group).
+    #[cfg(feature = "by_ref_proposal")]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn external_add_proposal(
+        &self,
+        group_info: &MlsMessage,
+        tree_data: Option<crate::group::ExportedTree<'_>>,
+        authenticated_data: Vec<u8>,
+    ) -> Result<MlsMessage, MlsError> {
+        let protocol_version = group_info.version;
+
+        if !self.config.version_supported(protocol_version) && protocol_version == self.version {
+            return Err(MlsError::UnsupportedProtocolVersion(protocol_version));
+        }
+
+        let group_info = group_info
+            .as_group_info()
+            .ok_or(MlsError::UnexpectedMessageType)?;
+
+        let cipher_suite = group_info.group_context.cipher_suite;
+
+        let cipher_suite_provider = self
+            .config
+            .crypto_provider()
+            .cipher_suite_provider(cipher_suite)
+            .ok_or(MlsError::UnsupportedCipherSuite(cipher_suite))?;
+
+        crate::group::validate_group_info_joiner(
+            protocol_version,
+            group_info,
+            tree_data,
+            &self.config.identity_provider(),
+            &cipher_suite_provider,
+        )
+        .await?;
+
+        let key_package = self.generate_key_package().await?.key_package;
+
+        (key_package.cipher_suite == cipher_suite)
+            .then_some(())
+            .ok_or(MlsError::UnsupportedCipherSuite(cipher_suite))?;
+
+        let message = AuthenticatedContent::new_signed(
+            &cipher_suite_provider,
+            &group_info.group_context,
+            Sender::NewMemberProposal,
+            Content::Proposal(Box::new(Proposal::Add(Box::new(AddProposal {
+                key_package,
+            })))),
+            self.signer()?,
+            WireFormat::PublicMessage,
+            authenticated_data,
+        )
+        .await?;
+
+        let plaintext = PublicMessage {
+            content: message.content,
+            auth: message.auth,
+            membership_tag: None,
+        };
+
+        Ok(MlsMessage {
+            version: protocol_version,
+            payload: MlsMessagePayload::Plain(plaintext),
+        })
+    }
+
+    fn signer(&self) -> Result<&SignatureSecretKey, MlsError> {
+        self.signer.as_ref().ok_or(MlsError::SignerNotFound)
+    }
+
+    #[cfg_attr(all(feature = "ffi", not(test)), safer_ffi_gen::safer_ffi_gen_ignore)]
+    pub fn signing_identity(&self) -> Result<(&SigningIdentity, CipherSuite), MlsError> {
+        self.signing_identity
+            .as_ref()
+            .map(|(id, cs)| (id, *cs))
+            .ok_or(MlsError::SignerNotFound)
+    }
+
+    /// Returns key package extensions used by this client
+    pub fn key_package_extensions(&self) -> ExtensionList {
+        self.config.key_package_extensions()
+    }
+
+    /// The [KeyPackageStorage] that this client was configured to use.
+    #[cfg_attr(all(feature = "ffi", not(test)), safer_ffi_gen::safer_ffi_gen_ignore)]
+    pub fn key_package_store(&self) -> <C as ClientConfig>::KeyPackageRepository {
+        self.config.key_package_repo()
+    }
+
+    /// The [PreSharedKeyStorage](crate::PreSharedKeyStorage) that
+    /// this client was configured to use.
+    #[cfg_attr(all(feature = "ffi", not(test)), safer_ffi_gen::safer_ffi_gen_ignore)]
+    pub fn secret_store(&self) -> <C as ClientConfig>::PskStore {
+        self.config.secret_store()
+    }
+
+    /// The [GroupStateStorage] that this client was configured to use.
+    #[cfg_attr(all(feature = "ffi", not(test)), safer_ffi_gen::safer_ffi_gen_ignore)]
+    pub fn group_state_storage(&self) -> <C as ClientConfig>::GroupStateStorage {
+        self.config.group_state_storage()
+    }
+}
+
+#[cfg(test)]
+pub(crate) mod test_utils {
+    use super::*;
+    use crate::identity::test_utils::get_test_signing_identity;
+
+    pub use crate::client_builder::test_utils::{TestClientBuilder, TestClientConfig};
+
+    pub const TEST_PROTOCOL_VERSION: ProtocolVersion = ProtocolVersion::MLS_10;
+    pub const TEST_CIPHER_SUITE: CipherSuite = CipherSuite::P256_AES128;
+    pub const TEST_CUSTOM_PROPOSAL_TYPE: ProposalType = ProposalType::new(65001);
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn test_client_with_key_pkg(
+        protocol_version: ProtocolVersion,
+        cipher_suite: CipherSuite,
+        identity: &str,
+    ) -> (Client<TestClientConfig>, MlsMessage) {
+        test_client_with_key_pkg_custom(protocol_version, cipher_suite, identity, |_| {}).await
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn test_client_with_key_pkg_custom<F>(
+        protocol_version: ProtocolVersion,
+        cipher_suite: CipherSuite,
+        identity: &str,
+        mut config: F,
+    ) -> (Client<TestClientConfig>, MlsMessage)
+    where
+        F: FnMut(&mut TestClientConfig),
+    {
+        let (identity, secret_key) =
+            get_test_signing_identity(cipher_suite, identity.as_bytes()).await;
+
+        let mut client = TestClientBuilder::new_for_test()
+            .used_protocol_version(protocol_version)
+            .signing_identity(identity.clone(), secret_key, cipher_suite)
+            .build();
+
+        config(&mut client.config);
+
+        let key_package = client.generate_key_package_message().await.unwrap();
+
+        (client, key_package)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::test_utils::*;
+
+    use super::*;
+    use crate::{
+        crypto::test_utils::TestCryptoProvider,
+        identity::test_utils::{get_test_basic_credential, get_test_signing_identity},
+        tree_kem::leaf_node::LeafNodeSource,
+    };
+    use assert_matches::assert_matches;
+
+    use crate::{
+        group::{
+            message_processor::ProposalMessageDescription,
+            proposal::Proposal,
+            test_utils::{test_group, test_group_custom_config},
+            ReceivedMessage,
+        },
+        psk::{ExternalPskId, PreSharedKey},
+    };
+
+    use alloc::vec;
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_keygen() {
+        // This is meant to test the inputs to the internal key package generator
+        // See KeyPackageGenerator tests for key generation specific tests
+        for (protocol_version, cipher_suite) in ProtocolVersion::all().flat_map(|p| {
+            TestCryptoProvider::all_supported_cipher_suites()
+                .into_iter()
+                .map(move |cs| (p, cs))
+        }) {
+            let (identity, secret_key) = get_test_signing_identity(cipher_suite, b"foo").await;
+
+            let client = TestClientBuilder::new_for_test()
+                .signing_identity(identity.clone(), secret_key, cipher_suite)
+                .build();
+
+            // TODO: Tests around extensions
+            let key_package = client.generate_key_package_message().await.unwrap();
+
+            assert_eq!(key_package.version, protocol_version);
+
+            let key_package = key_package.into_key_package().unwrap();
+
+            assert_eq!(key_package.cipher_suite, cipher_suite);
+
+            assert_eq!(
+                &key_package.leaf_node.signing_identity.credential,
+                &get_test_basic_credential(b"foo".to_vec())
+            );
+
+            assert_eq!(key_package.leaf_node.signing_identity, identity);
+
+            let capabilities = key_package.leaf_node.ungreased_capabilities();
+            assert_eq!(capabilities, client.config.capabilities());
+
+            let client_lifetime = client.config.lifetime();
+            assert_matches!(key_package.leaf_node.leaf_node_source, LeafNodeSource::KeyPackage(lifetime) if (lifetime.not_after - lifetime.not_before) == (client_lifetime.not_after - client_lifetime.not_before));
+        }
+    }
+
+    #[cfg(feature = "by_ref_proposal")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn new_member_add_proposal_adds_to_group() {
+        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.clone(), secret_key, TEST_CIPHER_SUITE)
+            .build();
+
+        let proposal = bob
+            .external_add_proposal(
+                &alice_group.group.group_info_message(true).await.unwrap(),
+                None,
+                vec![],
+            )
+            .await
+            .unwrap();
+
+        let message = alice_group
+            .group
+            .process_incoming_message(proposal)
+            .await
+            .unwrap();
+
+        assert_matches!(
+            message,
+            ReceivedMessage::Proposal(ProposalMessageDescription {
+                proposal: Proposal::Add(p), ..}
+            ) if p.key_package.leaf_node.signing_identity == bob_identity
+        );
+
+        alice_group.group.commit(vec![]).await.unwrap();
+        alice_group.group.apply_pending_commit().await.unwrap();
+
+        // Check that the new member is in the group
+        assert!(alice_group
+            .group
+            .roster()
+            .members_iter()
+            .any(|member| member.signing_identity == bob_identity))
+    }
+
+    #[cfg(feature = "psk")]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn join_via_external_commit(do_remove: bool, with_psk: bool) -> Result<(), MlsError> {
+        // An external commit cannot be the first commit in a group as it requires
+        // interim_transcript_hash to be computed from the confirmed_transcript_hash and
+        // confirmation_tag, which is not the case for the initial interim_transcript_hash.
+
+        let psk = PreSharedKey::from(b"psk".to_vec());
+        let psk_id = ExternalPskId::new(b"psk id".to_vec());
+
+        let mut alice_group =
+            test_group_custom_config(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, |c| {
+                c.psk(psk_id.clone(), psk.clone())
+            })
+            .await;
+
+        let (mut bob_group, _) = alice_group
+            .join_with_custom_config("bob", false, |c| {
+                c.0.psk_store.insert(psk_id.clone(), psk.clone());
+            })
+            .await
+            .unwrap();
+
+        let group_info_msg = alice_group
+            .group
+            .group_info_message_allowing_ext_commit(true)
+            .await
+            .unwrap();
+
+        let new_client_id = if do_remove { "bob" } else { "charlie" };
+
+        let (new_client_identity, secret_key) =
+            get_test_signing_identity(TEST_CIPHER_SUITE, new_client_id.as_bytes()).await;
+
+        let new_client = TestClientBuilder::new_for_test()
+            .psk(psk_id.clone(), psk)
+            .signing_identity(new_client_identity.clone(), secret_key, TEST_CIPHER_SUITE)
+            .build();
+
+        let mut builder = new_client.external_commit_builder().unwrap();
+
+        if do_remove {
+            builder = builder.with_removal(1);
+        }
+
+        if with_psk {
+            builder = builder.with_external_psk(psk_id);
+        }
+
+        let (new_group, external_commit) = builder.build(group_info_msg).await?;
+
+        let num_members = if do_remove { 2 } else { 3 };
+
+        assert_eq!(new_group.roster().members_iter().count(), num_members);
+
+        let _ = alice_group
+            .group
+            .process_incoming_message(external_commit.clone())
+            .await
+            .unwrap();
+
+        let bob_current_epoch = bob_group.group.current_epoch();
+
+        let message = bob_group
+            .group
+            .process_incoming_message(external_commit)
+            .await
+            .unwrap();
+
+        assert!(alice_group.group.roster().members_iter().count() == num_members);
+
+        if !do_remove {
+            assert!(bob_group.group.roster().members_iter().count() == num_members);
+        } else {
+            // Bob was removed so his epoch must stay the same
+            assert_eq!(bob_group.group.current_epoch(), bob_current_epoch);
+
+            #[cfg(feature = "state_update")]
+            assert_matches!(message, ReceivedMessage::Commit(desc) if !desc.state_update.active);
+
+            #[cfg(not(feature = "state_update"))]
+            assert_matches!(message, ReceivedMessage::Commit(_));
+        }
+
+        // Comparing epoch authenticators is sufficient to check that members are in sync.
+        assert_eq!(
+            alice_group.group.epoch_authenticator().unwrap(),
+            new_group.epoch_authenticator().unwrap()
+        );
+
+        Ok(())
+    }
+
+    #[cfg(feature = "psk")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_external_commit() {
+        // New member can join
+        join_via_external_commit(false, false).await.unwrap();
+        // New member can remove an old copy of themselves
+        join_via_external_commit(true, false).await.unwrap();
+        // New member can inject a PSK
+        join_via_external_commit(false, true).await.unwrap();
+        // All works together
+        join_via_external_commit(true, true).await.unwrap();
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn creating_an_external_commit_requires_a_group_info_message() {
+        let (alice_identity, secret_key) =
+            get_test_signing_identity(TEST_CIPHER_SUITE, b"alice").await;
+
+        let alice = TestClientBuilder::new_for_test()
+            .signing_identity(alice_identity.clone(), secret_key, TEST_CIPHER_SUITE)
+            .build();
+
+        let msg = alice.generate_key_package_message().await.unwrap();
+        let res = alice.commit_external(msg).await.map(|_| ());
+
+        assert_matches!(res, Err(MlsError::UnexpectedMessageType));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn external_commit_with_invalid_group_info_fails() {
+        let mut alice_group = test_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await;
+        let mut bob_group = test_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await;
+
+        bob_group.group.commit(vec![]).await.unwrap();
+        bob_group.group.apply_pending_commit().await.unwrap();
+
+        let group_info_msg = bob_group
+            .group
+            .group_info_message_allowing_ext_commit(true)
+            .await
+            .unwrap();
+
+        let (carol_identity, secret_key) =
+            get_test_signing_identity(TEST_CIPHER_SUITE, b"carol").await;
+
+        let carol = TestClientBuilder::new_for_test()
+            .signing_identity(carol_identity, secret_key, TEST_CIPHER_SUITE)
+            .build();
+
+        let (_, external_commit) = carol
+            .external_commit_builder()
+            .unwrap()
+            .build(group_info_msg)
+            .await
+            .unwrap();
+
+        // If Carol tries to join Alice's group using the group info from Bob's group, that fails.
+        let res = alice_group
+            .group
+            .process_incoming_message(external_commit)
+            .await;
+        assert_matches!(res, Err(_));
+    }
+
+    #[test]
+    fn builder_can_be_obtained_from_client_to_edit_properties_for_new_client() {
+        let alice = TestClientBuilder::new_for_test()
+            .extension_type(33.into())
+            .build();
+        let bob = alice.to_builder().extension_type(34.into()).build();
+        assert_eq!(bob.config.supported_extensions(), [33, 34].map(Into::into));
+    }
+}
diff --git a/src/client_builder.rs b/src/client_builder.rs
new file mode 100644
index 0000000..186c436
--- /dev/null
+++ b/src/client_builder.rs
@@ -0,0 +1,1029 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// Copyright by contributors to this project.
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+
+//! Definitions to build a [`Client`].
+//!
+//! See [`ClientBuilder`].
+
+use crate::{
+    cipher_suite::CipherSuite,
+    client::Client,
+    client_config::ClientConfig,
+    extension::{ExtensionType, MlsExtension},
+    group::{
+        mls_rules::{DefaultMlsRules, MlsRules},
+        proposal::ProposalType,
+    },
+    identity::CredentialType,
+    identity::SigningIdentity,
+    protocol_version::ProtocolVersion,
+    psk::{ExternalPskId, PreSharedKey},
+    storage_provider::in_memory::{
+        InMemoryGroupStateStorage, InMemoryKeyPackageStorage, InMemoryPreSharedKeyStorage,
+    },
+    tree_kem::{Capabilities, Lifetime},
+    Sealed,
+};
+
+#[cfg(feature = "std")]
+use crate::time::MlsTime;
+
+use alloc::vec::Vec;
+
+#[cfg(feature = "sqlite")]
+use mls_rs_provider_sqlite::{
+    SqLiteDataStorageEngine, SqLiteDataStorageError,
+    {
+        connection_strategy::ConnectionStrategy,
+        storage::{SqLiteGroupStateStorage, SqLiteKeyPackageStorage, SqLitePreSharedKeyStorage},
+    },
+};
+
+#[cfg(feature = "private_message")]
+pub use crate::group::padding::PaddingMode;
+
+/// Base client configuration type when instantiating `ClientBuilder`
+pub type BaseConfig = Config<
+    InMemoryKeyPackageStorage,
+    InMemoryPreSharedKeyStorage,
+    InMemoryGroupStateStorage,
+    Missing,
+    DefaultMlsRules,
+    Missing,
+>;
+
+/// Base client configuration type when instantiating `ClientBuilder`
+pub type BaseInMemoryConfig = Config<
+    InMemoryKeyPackageStorage,
+    InMemoryPreSharedKeyStorage,
+    InMemoryGroupStateStorage,
+    Missing,
+    Missing,
+    Missing,
+>;
+
+pub type EmptyConfig = Config<Missing, Missing, Missing, Missing, Missing, Missing>;
+
+/// Base client configuration that is backed by SQLite storage.
+#[cfg(feature = "sqlite")]
+pub type BaseSqlConfig = Config<
+    SqLiteKeyPackageStorage,
+    SqLitePreSharedKeyStorage,
+    SqLiteGroupStateStorage,
+    Missing,
+    DefaultMlsRules,
+    Missing,
+>;
+
+/// Builder for [`Client`]
+///
+/// This is returned by [`Client::builder`] and allows to tweak settings the `Client` will use. At a
+/// minimum, the builder must be told the [`CryptoProvider`] and [`IdentityProvider`] to use. Other
+/// settings have default values. This means that the following
+/// methods must be called before [`ClientBuilder::build`]:
+///
+/// - To specify the [`CryptoProvider`]: [`ClientBuilder::crypto_provider`]
+/// - To specify the [`IdentityProvider`]: [`ClientBuilder::identity_provider`]
+///
+/// # Example
+///
+/// ```
+/// use mls_rs::{
+///     Client,
+///     identity::{SigningIdentity, basic::{BasicIdentityProvider, BasicCredential}},
+///     CipherSuite,
+/// };
+///
+/// use mls_rs_crypto_openssl::OpensslCryptoProvider;
+///
+/// // Replace by code to load the certificate and secret key
+/// let secret_key = b"never hard-code secrets".to_vec().into();
+/// let public_key = b"test invalid public key".to_vec().into();
+/// let basic_identity = BasicCredential::new(b"name".to_vec());
+/// let signing_identity = SigningIdentity::new(basic_identity.into_credential(), public_key);
+///
+///
+/// let _client = Client::builder()
+///     .crypto_provider(OpensslCryptoProvider::default())
+///     .identity_provider(BasicIdentityProvider::new())
+///     .signing_identity(signing_identity, secret_key, CipherSuite::CURVE25519_AES128)
+///     .build();
+/// ```
+///
+/// # Spelling out a `Client` type
+///
+/// There are two main ways to spell out a `Client` type if needed (e.g. function return type).
+///
+/// The first option uses `impl MlsConfig`:
+/// ```
+/// use mls_rs::{
+///     Client,
+///     client_builder::MlsConfig,
+///     identity::{SigningIdentity, basic::{BasicIdentityProvider, BasicCredential}},
+///     CipherSuite,
+/// };
+///
+/// use mls_rs_crypto_openssl::OpensslCryptoProvider;
+///
+/// fn make_client() -> Client<impl MlsConfig> {
+///     // Replace by code to load the certificate and secret key
+///     let secret_key = b"never hard-code secrets".to_vec().into();
+///     let public_key = b"test invalid public key".to_vec().into();
+///     let basic_identity = BasicCredential::new(b"name".to_vec());
+///     let signing_identity = SigningIdentity::new(basic_identity.into_credential(), public_key);
+///
+///     Client::builder()
+///         .crypto_provider(OpensslCryptoProvider::default())
+///         .identity_provider(BasicIdentityProvider::new())
+///         .signing_identity(signing_identity, secret_key, CipherSuite::CURVE25519_AES128)
+///         .build()
+/// }
+///```
+///
+/// The second option is more verbose and consists in writing the full `Client` type:
+/// ```
+/// use mls_rs::{
+///     Client,
+///     client_builder::{BaseConfig, WithIdentityProvider, WithCryptoProvider},
+///     identity::{SigningIdentity, basic::{BasicIdentityProvider, BasicCredential}},
+///     CipherSuite,
+/// };
+///
+/// use mls_rs_crypto_openssl::OpensslCryptoProvider;
+///
+/// type MlsClient = Client<
+///     WithIdentityProvider<
+///         BasicIdentityProvider,
+///         WithCryptoProvider<OpensslCryptoProvider, BaseConfig>,
+///     >,
+/// >;
+///
+/// fn make_client_2() -> MlsClient {
+///     // Replace by code to load the certificate and secret key
+///     let secret_key = b"never hard-code secrets".to_vec().into();
+///     let public_key = b"test invalid public key".to_vec().into();
+///     let basic_identity = BasicCredential::new(b"name".to_vec());
+///     let signing_identity = SigningIdentity::new(basic_identity.into_credential(), public_key);
+///
+///     Client::builder()
+///         .crypto_provider(OpensslCryptoProvider::default())
+///         .identity_provider(BasicIdentityProvider::new())
+///         .signing_identity(signing_identity, secret_key, CipherSuite::CURVE25519_AES128)
+///         .build()
+/// }
+///
+/// ```
+#[derive(Debug)]
+pub struct ClientBuilder<C>(C);
+
+impl Default for ClientBuilder<BaseConfig> {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+impl<C> ClientBuilder<C> {
+    pub(crate) fn from_config(c: C) -> Self {
+        Self(c)
+    }
+}
+
+impl ClientBuilder<BaseConfig> {
+    /// Create a new client builder with default in-memory providers
+    pub fn new() -> Self {
+        Self(Config(ConfigInner {
+            settings: Default::default(),
+            key_package_repo: Default::default(),
+            psk_store: Default::default(),
+            group_state_storage: Default::default(),
+            identity_provider: Missing,
+            mls_rules: DefaultMlsRules::new(),
+            crypto_provider: Missing,
+            signer: Default::default(),
+            signing_identity: Default::default(),
+            version: ProtocolVersion::MLS_10,
+        }))
+    }
+}
+
+impl ClientBuilder<EmptyConfig> {
+    pub fn new_empty() -> Self {
+        Self(Config(ConfigInner {
+            settings: Default::default(),
+            key_package_repo: Missing,
+            psk_store: Missing,
+            group_state_storage: Missing,
+            identity_provider: Missing,
+            mls_rules: Missing,
+            crypto_provider: Missing,
+            signer: Default::default(),
+            signing_identity: Default::default(),
+            version: ProtocolVersion::MLS_10,
+        }))
+    }
+}
+
+#[cfg(feature = "sqlite")]
+impl ClientBuilder<BaseSqlConfig> {
+    /// Create a new client builder with SQLite storage providers.
+    pub fn new_sqlite<CS: ConnectionStrategy>(
+        storage: SqLiteDataStorageEngine<CS>,
+    ) -> Result<Self, SqLiteDataStorageError> {
+        Ok(Self(Config(ConfigInner {
+            settings: Default::default(),
+            key_package_repo: storage.key_package_storage()?,
+            psk_store: storage.pre_shared_key_storage()?,
+            group_state_storage: storage.group_state_storage()?,
+            identity_provider: Missing,
+            mls_rules: DefaultMlsRules::new(),
+            crypto_provider: Missing,
+            signer: Default::default(),
+            signing_identity: Default::default(),
+            version: ProtocolVersion::MLS_10,
+        })))
+    }
+}
+
+impl<C: IntoConfig> ClientBuilder<C> {
+    /// Add an extension type to the list of extension types supported by the client.
+    pub fn extension_type(self, type_: ExtensionType) -> ClientBuilder<IntoConfigOutput<C>> {
+        self.extension_types(Some(type_))
+    }
+
+    /// Add multiple extension types to the list of extension types supported by the client.
+    pub fn extension_types<I>(self, types: I) -> ClientBuilder<IntoConfigOutput<C>>
+    where
+        I: IntoIterator<Item = ExtensionType>,
+    {
+        let mut c = self.0.into_config();
+        c.0.settings.extension_types.extend(types);
+        ClientBuilder(c)
+    }
+
+    /// Add a custom proposal type to the list of proposals types supported by the client.
+    pub fn custom_proposal_type(self, type_: ProposalType) -> ClientBuilder<IntoConfigOutput<C>> {
+        self.custom_proposal_types(Some(type_))
+    }
+
+    /// Add multiple custom proposal types to the list of proposal types supported by the client.
+    pub fn custom_proposal_types<I>(self, types: I) -> ClientBuilder<IntoConfigOutput<C>>
+    where
+        I: IntoIterator<Item = ProposalType>,
+    {
+        let mut c = self.0.into_config();
+        c.0.settings.custom_proposal_types.extend(types);
+        ClientBuilder(c)
+    }
+
+    /// Add a protocol version to the list of protocol versions supported by the client.
+    ///
+    /// If no protocol version is explicitly added, the client will support all protocol versions
+    /// supported by this crate.
+    pub fn protocol_version(self, version: ProtocolVersion) -> ClientBuilder<IntoConfigOutput<C>> {
+        self.protocol_versions(Some(version))
+    }
+
+    /// Add multiple protocol versions to the list of protocol versions supported by the client.
+    ///
+    /// If no protocol version is explicitly added, the client will support all protocol versions
+    /// supported by this crate.
+    pub fn protocol_versions<I>(self, versions: I) -> ClientBuilder<IntoConfigOutput<C>>
+    where
+        I: IntoIterator<Item = ProtocolVersion>,
+    {
+        let mut c = self.0.into_config();
+        c.0.settings.protocol_versions.extend(versions);
+        ClientBuilder(c)
+    }
+
+    /// Add a key package extension to the list of key package extensions supported by the client.
+    pub fn key_package_extension<T>(
+        self,
+        extension: T,
+    ) -> Result<ClientBuilder<IntoConfigOutput<C>>, ExtensionError>
+    where
+        T: MlsExtension,
+        Self: Sized,
+    {
+        let mut c = self.0.into_config();
+        c.0.settings.key_package_extensions.set_from(extension)?;
+        Ok(ClientBuilder(c))
+    }
+
+    /// Add multiple key package extensions to the list of key package extensions supported by the
+    /// client.
+    pub fn key_package_extensions(
+        self,
+        extensions: ExtensionList,
+    ) -> ClientBuilder<IntoConfigOutput<C>> {
+        let mut c = self.0.into_config();
+        c.0.settings.key_package_extensions.append(extensions);
+        ClientBuilder(c)
+    }
+
+    /// Add a leaf node extension to the list of leaf node extensions supported by the client.
+    pub fn leaf_node_extension<T>(
+        self,
+        extension: T,
+    ) -> Result<ClientBuilder<IntoConfigOutput<C>>, ExtensionError>
+    where
+        T: MlsExtension,
+        Self: Sized,
+    {
+        let mut c = self.0.into_config();
+        c.0.settings.leaf_node_extensions.set_from(extension)?;
+        Ok(ClientBuilder(c))
+    }
+
+    /// Add multiple leaf node extensions to the list of leaf node extensions supported by the
+    /// client.
+    pub fn leaf_node_extensions(
+        self,
+        extensions: ExtensionList,
+    ) -> ClientBuilder<IntoConfigOutput<C>> {
+        let mut c = self.0.into_config();
+        c.0.settings.leaf_node_extensions.append(extensions);
+        ClientBuilder(c)
+    }
+
+    /// Set the lifetime duration in seconds of key packages generated by the client.
+    pub fn key_package_lifetime(self, duration_in_s: u64) -> ClientBuilder<IntoConfigOutput<C>> {
+        let mut c = self.0.into_config();
+        c.0.settings.lifetime_in_s = duration_in_s;
+        ClientBuilder(c)
+    }
+
+    /// Set the key package repository to be used by the client.
+    ///
+    /// By default, an in-memory repository is used.
+    pub fn key_package_repo<K>(self, key_package_repo: K) -> ClientBuilder<WithKeyPackageRepo<K, C>>
+    where
+        K: KeyPackageStorage,
+    {
+        let Config(c) = self.0.into_config();
+
+        ClientBuilder(Config(ConfigInner {
+            settings: c.settings,
+            key_package_repo,
+            psk_store: c.psk_store,
+            group_state_storage: c.group_state_storage,
+            identity_provider: c.identity_provider,
+            mls_rules: c.mls_rules,
+            crypto_provider: c.crypto_provider,
+            signer: c.signer,
+            signing_identity: c.signing_identity,
+            version: c.version,
+        }))
+    }
+
+    /// Set the PSK store to be used by the client.
+    ///
+    /// By default, an in-memory store is used.
+    pub fn psk_store<P>(self, psk_store: P) -> ClientBuilder<WithPskStore<P, C>>
+    where
+        P: PreSharedKeyStorage,
+    {
+        let Config(c) = self.0.into_config();
+
+        ClientBuilder(Config(ConfigInner {
+            settings: c.settings,
+            key_package_repo: c.key_package_repo,
+            psk_store,
+            group_state_storage: c.group_state_storage,
+            identity_provider: c.identity_provider,
+            mls_rules: c.mls_rules,
+            crypto_provider: c.crypto_provider,
+            signer: c.signer,
+            signing_identity: c.signing_identity,
+            version: c.version,
+        }))
+    }
+
+    /// Set the group state storage to be used by the client.
+    ///
+    /// By default, an in-memory storage is used.
+    pub fn group_state_storage<G>(
+        self,
+        group_state_storage: G,
+    ) -> ClientBuilder<WithGroupStateStorage<G, C>>
+    where
+        G: GroupStateStorage,
+    {
+        let Config(c) = self.0.into_config();
+
+        ClientBuilder(Config(ConfigInner {
+            settings: c.settings,
+            key_package_repo: c.key_package_repo,
+            psk_store: c.psk_store,
+            group_state_storage,
+            identity_provider: c.identity_provider,
+            crypto_provider: c.crypto_provider,
+            mls_rules: c.mls_rules,
+            signer: c.signer,
+            signing_identity: c.signing_identity,
+            version: c.version,
+        }))
+    }
+
+    /// Set the identity validator to be used by the client.
+    pub fn identity_provider<I>(
+        self,
+        identity_provider: I,
+    ) -> ClientBuilder<WithIdentityProvider<I, C>>
+    where
+        I: IdentityProvider,
+    {
+        let Config(c) = self.0.into_config();
+
+        ClientBuilder(Config(ConfigInner {
+            settings: c.settings,
+            key_package_repo: c.key_package_repo,
+            psk_store: c.psk_store,
+            group_state_storage: c.group_state_storage,
+            identity_provider,
+            mls_rules: c.mls_rules,
+            crypto_provider: c.crypto_provider,
+            signer: c.signer,
+            signing_identity: c.signing_identity,
+            version: c.version,
+        }))
+    }
+
+    /// Set the crypto provider to be used by the client.
+    pub fn crypto_provider<Cp>(
+        self,
+        crypto_provider: Cp,
+    ) -> ClientBuilder<WithCryptoProvider<Cp, C>>
+    where
+        Cp: CryptoProvider,
+    {
+        let Config(c) = self.0.into_config();
+
+        ClientBuilder(Config(ConfigInner {
+            settings: c.settings,
+            key_package_repo: c.key_package_repo,
+            psk_store: c.psk_store,
+            group_state_storage: c.group_state_storage,
+            identity_provider: c.identity_provider,
+            mls_rules: c.mls_rules,
+            crypto_provider,
+            signer: c.signer,
+            signing_identity: c.signing_identity,
+            version: c.version,
+        }))
+    }
+
+    /// Set the user-defined proposal rules to be used by the client.
+    ///
+    /// User-defined rules are used when sending and receiving commits before
+    /// enforcing general MLS protocol rules. If the rule set returns an error when
+    /// receiving a commit, the entire commit is considered invalid. If the
+    /// rule set would return an error when sending a commit, individual proposals
+    /// may be filtered out to compensate.
+    pub fn mls_rules<Pr>(self, mls_rules: Pr) -> ClientBuilder<WithMlsRules<Pr, C>>
+    where
+        Pr: MlsRules,
+    {
+        let Config(c) = self.0.into_config();
+
+        ClientBuilder(Config(ConfigInner {
+            settings: c.settings,
+            key_package_repo: c.key_package_repo,
+            psk_store: c.psk_store,
+            group_state_storage: c.group_state_storage,
+            identity_provider: c.identity_provider,
+            mls_rules,
+            crypto_provider: c.crypto_provider,
+            signer: c.signer,
+            signing_identity: c.signing_identity,
+            version: c.version,
+        }))
+    }
+
+    /// Set the protocol version used by the client. By default, the client uses version MLS 1.0
+    pub fn used_protocol_version(
+        self,
+        version: ProtocolVersion,
+    ) -> ClientBuilder<IntoConfigOutput<C>> {
+        let mut c = self.0.into_config();
+        c.0.version = version;
+        ClientBuilder(c)
+    }
+
+    /// Set the signing identity used by the client as well as the matching signer and cipher suite.
+    /// This must be called in order to create groups and key packages.
+    pub fn signing_identity(
+        self,
+        signing_identity: SigningIdentity,
+        signer: SignatureSecretKey,
+        cipher_suite: CipherSuite,
+    ) -> ClientBuilder<IntoConfigOutput<C>> {
+        let mut c = self.0.into_config();
+        c.0.signer = Some(signer);
+        c.0.signing_identity = Some((signing_identity, cipher_suite));
+        ClientBuilder(c)
+    }
+
+    /// Set the signer used by the client. This must be called in order to join groups.
+    pub fn signer(self, signer: SignatureSecretKey) -> ClientBuilder<IntoConfigOutput<C>> {
+        let mut c = self.0.into_config();
+        c.0.signer = Some(signer);
+        ClientBuilder(c)
+    }
+
+    #[cfg(any(test, feature = "test_util"))]
+    pub(crate) fn key_package_not_before(
+        self,
+        key_package_not_before: u64,
+    ) -> ClientBuilder<IntoConfigOutput<C>> {
+        let mut c = self.0.into_config();
+        c.0.settings.key_package_not_before = Some(key_package_not_before);
+        ClientBuilder(c)
+    }
+}
+
+impl<C: IntoConfig> ClientBuilder<C>
+where
+    C::KeyPackageRepository: KeyPackageStorage + Clone,
+    C::PskStore: PreSharedKeyStorage + Clone,
+    C::GroupStateStorage: GroupStateStorage + Clone,
+    C::IdentityProvider: IdentityProvider + Clone,
+    C::MlsRules: MlsRules + Clone,
+    C::CryptoProvider: CryptoProvider + Clone,
+{
+    pub(crate) fn build_config(self) -> IntoConfigOutput<C> {
+        let mut c = self.0.into_config();
+
+        if c.0.settings.protocol_versions.is_empty() {
+            c.0.settings.protocol_versions = ProtocolVersion::all().collect();
+        }
+
+        c
+    }
+
+    /// Build a client.
+    ///
+    /// See [`ClientBuilder`] documentation if the return type of this function needs to be spelled
+    /// out.
+    pub fn build(self) -> Client<IntoConfigOutput<C>> {
+        let mut c = self.build_config();
+        let version = c.0.version;
+        let signer = c.0.signer.take();
+        let signing_identity = c.0.signing_identity.take();
+
+        Client::new(c, signer, signing_identity, version)
+    }
+}
+
+impl<C: IntoConfig<PskStore = InMemoryPreSharedKeyStorage>> ClientBuilder<C> {
+    /// Add a PSK to the in-memory PSK store.
+    pub fn psk(
+        self,
+        psk_id: ExternalPskId,
+        psk: PreSharedKey,
+    ) -> ClientBuilder<IntoConfigOutput<C>> {
+        let mut c = self.0.into_config();
+        c.0.psk_store.insert(psk_id, psk);
+        ClientBuilder(c)
+    }
+}
+
+/// Marker type for required `ClientBuilder` services that have not been specified yet.
+#[derive(Debug)]
+pub struct Missing;
+
+/// Change the key package repository used by a client configuration.
+///
+/// See [`ClientBuilder::key_package_repo`].
+pub type WithKeyPackageRepo<K, C> = Config<
+    K,
+    <C as IntoConfig>::PskStore,
+    <C as IntoConfig>::GroupStateStorage,
+    <C as IntoConfig>::IdentityProvider,
+    <C as IntoConfig>::MlsRules,
+    <C as IntoConfig>::CryptoProvider,
+>;
+
+/// Change the PSK store used by a client configuration.
+///
+/// See [`ClientBuilder::psk_store`].
+pub type WithPskStore<P, C> = Config<
+    <C as IntoConfig>::KeyPackageRepository,
+    P,
+    <C as IntoConfig>::GroupStateStorage,
+    <C as IntoConfig>::IdentityProvider,
+    <C as IntoConfig>::MlsRules,
+    <C as IntoConfig>::CryptoProvider,
+>;
+
+/// Change the group state storage used by a client configuration.
+///
+/// See [`ClientBuilder::group_state_storage`].
+pub type WithGroupStateStorage<G, C> = Config<
+    <C as IntoConfig>::KeyPackageRepository,
+    <C as IntoConfig>::PskStore,
+    G,
+    <C as IntoConfig>::IdentityProvider,
+    <C as IntoConfig>::MlsRules,
+    <C as IntoConfig>::CryptoProvider,
+>;
+
+/// Change the identity validator used by a client configuration.
+///
+/// See [`ClientBuilder::identity_provider`].
+pub type WithIdentityProvider<I, C> = Config<
+    <C as IntoConfig>::KeyPackageRepository,
+    <C as IntoConfig>::PskStore,
+    <C as IntoConfig>::GroupStateStorage,
+    I,
+    <C as IntoConfig>::MlsRules,
+    <C as IntoConfig>::CryptoProvider,
+>;
+
+/// Change the proposal rules used by a client configuration.
+///
+/// See [`ClientBuilder::mls_rules`].
+pub type WithMlsRules<Pr, C> = Config<
+    <C as IntoConfig>::KeyPackageRepository,
+    <C as IntoConfig>::PskStore,
+    <C as IntoConfig>::GroupStateStorage,
+    <C as IntoConfig>::IdentityProvider,
+    Pr,
+    <C as IntoConfig>::CryptoProvider,
+>;
+
+/// Change the crypto provider used by a client configuration.
+///
+/// See [`ClientBuilder::crypto_provider`].
+pub type WithCryptoProvider<Cp, C> = Config<
+    <C as IntoConfig>::KeyPackageRepository,
+    <C as IntoConfig>::PskStore,
+    <C as IntoConfig>::GroupStateStorage,
+    <C as IntoConfig>::IdentityProvider,
+    <C as IntoConfig>::MlsRules,
+    Cp,
+>;
+
+/// Helper alias for `Config`.
+pub type IntoConfigOutput<C> = Config<
+    <C as IntoConfig>::KeyPackageRepository,
+    <C as IntoConfig>::PskStore,
+    <C as IntoConfig>::GroupStateStorage,
+    <C as IntoConfig>::IdentityProvider,
+    <C as IntoConfig>::MlsRules,
+    <C as IntoConfig>::CryptoProvider,
+>;
+
+/// Helper alias to make a `Config` from a `ClientConfig`
+pub type MakeConfig<C> = Config<
+    <C as ClientConfig>::KeyPackageRepository,
+    <C as ClientConfig>::PskStore,
+    <C as ClientConfig>::GroupStateStorage,
+    <C as ClientConfig>::IdentityProvider,
+    <C as ClientConfig>::MlsRules,
+    <C as ClientConfig>::CryptoProvider,
+>;
+
+impl<Kpr, Ps, Gss, Ip, Pr, Cp> ClientConfig for ConfigInner<Kpr, Ps, Gss, Ip, Pr, Cp>
+where
+    Kpr: KeyPackageStorage + Clone,
+    Ps: PreSharedKeyStorage + Clone,
+    Gss: GroupStateStorage + Clone,
+    Ip: IdentityProvider + Clone,
+    Pr: MlsRules + Clone,
+    Cp: CryptoProvider + Clone,
+{
+    type KeyPackageRepository = Kpr;
+    type PskStore = Ps;
+    type GroupStateStorage = Gss;
+    type IdentityProvider = Ip;
+    type MlsRules = Pr;
+    type CryptoProvider = Cp;
+
+    fn supported_extensions(&self) -> Vec<ExtensionType> {
+        self.settings.extension_types.clone()
+    }
+
+    fn supported_protocol_versions(&self) -> Vec<ProtocolVersion> {
+        self.settings.protocol_versions.clone()
+    }
+
+    fn key_package_repo(&self) -> Self::KeyPackageRepository {
+        self.key_package_repo.clone()
+    }
+
+    fn mls_rules(&self) -> Self::MlsRules {
+        self.mls_rules.clone()
+    }
+
+    fn secret_store(&self) -> Self::PskStore {
+        self.psk_store.clone()
+    }
+
+    fn group_state_storage(&self) -> Self::GroupStateStorage {
+        self.group_state_storage.clone()
+    }
+
+    fn identity_provider(&self) -> Self::IdentityProvider {
+        self.identity_provider.clone()
+    }
+
+    fn crypto_provider(&self) -> Self::CryptoProvider {
+        self.crypto_provider.clone()
+    }
+
+    fn key_package_extensions(&self) -> ExtensionList {
+        self.settings.key_package_extensions.clone()
+    }
+
+    fn leaf_node_extensions(&self) -> ExtensionList {
+        self.settings.leaf_node_extensions.clone()
+    }
+
+    fn lifetime(&self) -> Lifetime {
+        #[cfg(feature = "std")]
+        let now_timestamp = MlsTime::now().seconds_since_epoch();
+
+        #[cfg(not(feature = "std"))]
+        let now_timestamp = 0;
+
+        #[cfg(test)]
+        let now_timestamp = self
+            .settings
+            .key_package_not_before
+            .unwrap_or(now_timestamp);
+
+        Lifetime {
+            not_before: now_timestamp,
+            not_after: now_timestamp + self.settings.lifetime_in_s,
+        }
+    }
+
+    fn supported_custom_proposals(&self) -> Vec<crate::group::proposal::ProposalType> {
+        self.settings.custom_proposal_types.clone()
+    }
+}
+
+impl<Kpr, Ps, Gss, Ip, Pr, Cp> Sealed for Config<Kpr, Ps, Gss, Ip, Pr, Cp> {}
+
+impl<Kpr, Ps, Gss, Ip, Pr, Cp> MlsConfig for Config<Kpr, Ps, Gss, Ip, Pr, Cp>
+where
+    Kpr: KeyPackageStorage + Clone,
+
+    Ps: PreSharedKeyStorage + Clone,
+    Gss: GroupStateStorage + Clone,
+    Ip: IdentityProvider + Clone,
+    Pr: MlsRules + Clone,
+    Cp: CryptoProvider + Clone,
+{
+    type Output = ConfigInner<Kpr, Ps, Gss, Ip, Pr, Cp>;
+
+    fn get(&self) -> &Self::Output {
+        &self.0
+    }
+}
+
+/// Helper trait to allow consuming crates to easily write a client type as `Client<impl MlsConfig>`
+///
+/// It is not meant to be implemented by consuming crates. `T: MlsConfig` implies `T: ClientConfig`.
+pub trait MlsConfig: Clone + Send + Sync + Sealed {
+    #[doc(hidden)]
+    type Output: ClientConfig;
+
+    #[doc(hidden)]
+    fn get(&self) -> &Self::Output;
+}
+
+/// Blanket implementation so that `T: MlsConfig` implies `T: ClientConfig`
+impl<T: MlsConfig> ClientConfig for T {
+    type KeyPackageRepository = <T::Output as ClientConfig>::KeyPackageRepository;
+    type PskStore = <T::Output as ClientConfig>::PskStore;
+    type GroupStateStorage = <T::Output as ClientConfig>::GroupStateStorage;
+    type IdentityProvider = <T::Output as ClientConfig>::IdentityProvider;
+    type MlsRules = <T::Output as ClientConfig>::MlsRules;
+    type CryptoProvider = <T::Output as ClientConfig>::CryptoProvider;
+
+    fn supported_extensions(&self) -> Vec<ExtensionType> {
+        self.get().supported_extensions()
+    }
+
+    fn supported_custom_proposals(&self) -> Vec<ProposalType> {
+        self.get().supported_custom_proposals()
+    }
+
+    fn supported_protocol_versions(&self) -> Vec<ProtocolVersion> {
+        self.get().supported_protocol_versions()
+    }
+
+    fn key_package_repo(&self) -> Self::KeyPackageRepository {
+        self.get().key_package_repo()
+    }
+
+    fn mls_rules(&self) -> Self::MlsRules {
+        self.get().mls_rules()
+    }
+
+    fn secret_store(&self) -> Self::PskStore {
+        self.get().secret_store()
+    }
+
+    fn group_state_storage(&self) -> Self::GroupStateStorage {
+        self.get().group_state_storage()
+    }
+
+    fn identity_provider(&self) -> Self::IdentityProvider {
+        self.get().identity_provider()
+    }
+
+    fn crypto_provider(&self) -> Self::CryptoProvider {
+        self.get().crypto_provider()
+    }
+
+    fn key_package_extensions(&self) -> ExtensionList {
+        self.get().key_package_extensions()
+    }
+
+    fn leaf_node_extensions(&self) -> ExtensionList {
+        self.get().leaf_node_extensions()
+    }
+
+    fn lifetime(&self) -> Lifetime {
+        self.get().lifetime()
+    }
+
+    fn capabilities(&self) -> Capabilities {
+        self.get().capabilities()
+    }
+
+    fn version_supported(&self, version: ProtocolVersion) -> bool {
+        self.get().version_supported(version)
+    }
+
+    fn supported_credential_types(&self) -> Vec<CredentialType> {
+        self.get().supported_credential_types()
+    }
+}
+
+#[derive(Clone, Debug)]
+pub(crate) struct Settings {
+    pub(crate) extension_types: Vec<ExtensionType>,
+    pub(crate) protocol_versions: Vec<ProtocolVersion>,
+    pub(crate) custom_proposal_types: Vec<ProposalType>,
+    pub(crate) key_package_extensions: ExtensionList,
+    pub(crate) leaf_node_extensions: ExtensionList,
+    pub(crate) lifetime_in_s: u64,
+    #[cfg(any(test, feature = "test_util"))]
+    pub(crate) key_package_not_before: Option<u64>,
+}
+
+impl Default for Settings {
+    fn default() -> Self {
+        Self {
+            extension_types: Default::default(),
+            protocol_versions: Default::default(),
+            key_package_extensions: Default::default(),
+            leaf_node_extensions: Default::default(),
+            lifetime_in_s: 365 * 24 * 3600,
+            custom_proposal_types: Default::default(),
+            #[cfg(any(test, feature = "test_util"))]
+            key_package_not_before: None,
+        }
+    }
+}
+
+pub(crate) fn recreate_config<T: ClientConfig>(
+    c: T,
+    signer: Option<SignatureSecretKey>,
+    signing_identity: Option<(SigningIdentity, CipherSuite)>,
+    version: ProtocolVersion,
+) -> MakeConfig<T> {
+    Config(ConfigInner {
+        settings: Settings {
+            extension_types: c.supported_extensions(),
+            protocol_versions: c.supported_protocol_versions(),
+            custom_proposal_types: c.supported_custom_proposals(),
+            key_package_extensions: c.key_package_extensions(),
+            leaf_node_extensions: c.leaf_node_extensions(),
+            lifetime_in_s: {
+                let l = c.lifetime();
+                l.not_after - l.not_before
+            },
+            #[cfg(any(test, feature = "test_util"))]
+            key_package_not_before: None,
+        },
+        key_package_repo: c.key_package_repo(),
+        psk_store: c.secret_store(),
+        group_state_storage: c.group_state_storage(),
+        identity_provider: c.identity_provider(),
+        mls_rules: c.mls_rules(),
+        crypto_provider: c.crypto_provider(),
+        signer,
+        signing_identity,
+        version,
+    })
+}
+
+/// Definitions meant to be private that are inaccessible outside this crate. They need to be marked
+/// `pub` because they appear in public definitions.
+mod private {
+    use mls_rs_core::{
+        crypto::{CipherSuite, SignatureSecretKey},
+        identity::SigningIdentity,
+        protocol_version::ProtocolVersion,
+    };
+
+    use crate::client_builder::{IntoConfigOutput, Settings};
+
+    #[derive(Clone, Debug)]
+    pub struct Config<Kpr, Ps, Gss, Ip, Pr, Cp>(pub(crate) ConfigInner<Kpr, Ps, Gss, Ip, Pr, Cp>);
+
+    #[derive(Clone, Debug)]
+    pub struct ConfigInner<Kpr, Ps, Gss, Ip, Pr, Cp> {
+        pub(crate) settings: Settings,
+        pub(crate) key_package_repo: Kpr,
+        pub(crate) psk_store: Ps,
+        pub(crate) group_state_storage: Gss,
+        pub(crate) identity_provider: Ip,
+        pub(crate) mls_rules: Pr,
+        pub(crate) crypto_provider: Cp,
+        pub(crate) signer: Option<SignatureSecretKey>,
+        pub(crate) signing_identity: Option<(SigningIdentity, CipherSuite)>,
+        pub(crate) version: ProtocolVersion,
+    }
+
+    pub trait IntoConfig {
+        type KeyPackageRepository;
+        type PskStore;
+        type GroupStateStorage;
+        type IdentityProvider;
+        type MlsRules;
+        type CryptoProvider;
+
+        fn into_config(self) -> IntoConfigOutput<Self>;
+    }
+
+    impl<Kpr, Ps, Gss, Ip, Pr, Cp> IntoConfig for Config<Kpr, Ps, Gss, Ip, Pr, Cp> {
+        type KeyPackageRepository = Kpr;
+        type PskStore = Ps;
+        type GroupStateStorage = Gss;
+        type IdentityProvider = Ip;
+        type MlsRules = Pr;
+        type CryptoProvider = Cp;
+
+        fn into_config(self) -> Self {
+            self
+        }
+    }
+}
+
+use mls_rs_core::{
+    crypto::{CryptoProvider, SignatureSecretKey},
+    extension::{ExtensionError, ExtensionList},
+    group::GroupStateStorage,
+    identity::IdentityProvider,
+    key_package::KeyPackageStorage,
+    psk::PreSharedKeyStorage,
+};
+use private::{Config, ConfigInner, IntoConfig};
+
+#[cfg(test)]
+pub(crate) mod test_utils {
+    use crate::{
+        client_builder::{BaseConfig, ClientBuilder, WithIdentityProvider},
+        crypto::test_utils::TestCryptoProvider,
+        identity::{
+            basic::BasicIdentityProvider,
+            test_utils::{get_test_signing_identity, BasicWithCustomProvider},
+        },
+        CipherSuite,
+    };
+
+    use super::WithCryptoProvider;
+
+    pub type TestClientConfig = WithIdentityProvider<
+        BasicWithCustomProvider,
+        WithCryptoProvider<TestCryptoProvider, BaseConfig>,
+    >;
+
+    pub type TestClientBuilder = ClientBuilder<TestClientConfig>;
+
+    impl TestClientBuilder {
+        pub fn new_for_test() -> Self {
+            ClientBuilder::new()
+                .crypto_provider(TestCryptoProvider::new())
+                .identity_provider(BasicWithCustomProvider::new(BasicIdentityProvider::new()))
+        }
+
+        #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+        pub async fn with_random_signing_identity(
+            self,
+            identity: &str,
+            cipher_suite: CipherSuite,
+        ) -> Self {
+            let (signing_identity, signer) =
+                get_test_signing_identity(cipher_suite, identity.as_bytes()).await;
+            self.signing_identity(signing_identity, signer, cipher_suite)
+        }
+    }
+}
diff --git a/src/client_config.rs b/src/client_config.rs
new file mode 100644
index 0000000..339f335
--- /dev/null
+++ b/src/client_config.rs
@@ -0,0 +1,68 @@
+// 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::{
+    extension::ExtensionType,
+    group::{mls_rules::MlsRules, proposal::ProposalType},
+    identity::CredentialType,
+    protocol_version::ProtocolVersion,
+    tree_kem::{leaf_node::ConfigProperties, Capabilities, Lifetime},
+    ExtensionList,
+};
+use alloc::vec::Vec;
+use mls_rs_core::{
+    crypto::CryptoProvider, group::GroupStateStorage, identity::IdentityProvider,
+    key_package::KeyPackageStorage, psk::PreSharedKeyStorage,
+};
+
+pub trait ClientConfig: Send + Sync + Clone {
+    type KeyPackageRepository: KeyPackageStorage + Clone;
+    type PskStore: PreSharedKeyStorage + Clone;
+    type GroupStateStorage: GroupStateStorage + Clone;
+    type IdentityProvider: IdentityProvider + Clone;
+    type MlsRules: MlsRules + Clone;
+    type CryptoProvider: CryptoProvider + Clone;
+
+    fn supported_extensions(&self) -> Vec<ExtensionType>;
+    fn supported_custom_proposals(&self) -> Vec<ProposalType>;
+    fn supported_protocol_versions(&self) -> Vec<ProtocolVersion>;
+
+    fn key_package_repo(&self) -> Self::KeyPackageRepository;
+
+    fn mls_rules(&self) -> Self::MlsRules;
+
+    fn secret_store(&self) -> Self::PskStore;
+    fn group_state_storage(&self) -> Self::GroupStateStorage;
+    fn identity_provider(&self) -> Self::IdentityProvider;
+    fn crypto_provider(&self) -> Self::CryptoProvider;
+
+    fn key_package_extensions(&self) -> ExtensionList;
+    fn leaf_node_extensions(&self) -> ExtensionList;
+    fn lifetime(&self) -> Lifetime;
+
+    fn capabilities(&self) -> Capabilities {
+        Capabilities {
+            protocol_versions: self.supported_protocol_versions(),
+            cipher_suites: self.crypto_provider().supported_cipher_suites(),
+            extensions: self.supported_extensions(),
+            proposals: self.supported_custom_proposals(),
+            credentials: self.supported_credential_types(),
+        }
+    }
+
+    fn version_supported(&self, version: ProtocolVersion) -> bool {
+        self.supported_protocol_versions().contains(&version)
+    }
+
+    fn supported_credential_types(&self) -> Vec<CredentialType> {
+        self.identity_provider().supported_types()
+    }
+
+    fn leaf_properties(&self) -> ConfigProperties {
+        ConfigProperties {
+            capabilities: self.capabilities(),
+            extensions: self.leaf_node_extensions(),
+        }
+    }
+}
diff --git a/src/crypto.rs b/src/crypto.rs
new file mode 100644
index 0000000..795476a
--- /dev/null
+++ b/src/crypto.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)
+
+pub(crate) use mls_rs_core::crypto::CipherSuiteProvider;
+
+pub use mls_rs_core::crypto::{
+    HpkeCiphertext, HpkeContextR, HpkeContextS, HpkePublicKey, HpkeSecretKey, SignaturePublicKey,
+    SignatureSecretKey,
+};
+
+pub use mls_rs_core::secret::Secret;
+
+#[cfg(test)]
+pub(crate) mod test_utils {
+    use cfg_if::cfg_if;
+    use mls_rs_core::crypto::CryptoProvider;
+
+    cfg_if! {
+        if #[cfg(target_arch = "wasm32")] {
+            pub use mls_rs_crypto_webcrypto::WebCryptoProvider as TestCryptoProvider;
+        } else {
+            pub use mls_rs_crypto_openssl::OpensslCryptoProvider as TestCryptoProvider;
+        }
+    }
+
+    use crate::cipher_suite::CipherSuite;
+
+    pub fn test_cipher_suite_provider(
+        cipher_suite: CipherSuite,
+    ) -> <TestCryptoProvider as CryptoProvider>::CipherSuiteProvider {
+        TestCryptoProvider::new()
+            .cipher_suite_provider(cipher_suite)
+            .unwrap()
+    }
+
+    #[allow(unused)]
+    pub fn try_test_cipher_suite_provider(
+        cipher_suite: u16,
+    ) -> Option<<TestCryptoProvider as CryptoProvider>::CipherSuiteProvider> {
+        TestCryptoProvider::new().cipher_suite_provider(CipherSuite::from(cipher_suite))
+    }
+}
diff --git a/src/extension.rs b/src/extension.rs
new file mode 100644
index 0000000..4cba416
--- /dev/null
+++ b/src/extension.rs
@@ -0,0 +1,52 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// Copyright by contributors to this project.
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+
+pub use mls_rs_core::extension::{ExtensionType, MlsCodecExtension, MlsExtension};
+
+pub(crate) use built_in::*;
+
+/// Default extension types required by the MLS RFC.
+pub mod built_in;
+
+#[cfg(test)]
+pub(crate) mod test_utils {
+    use alloc::vec::Vec;
+    use core::convert::Infallible;
+    use core::fmt::Debug;
+    use mls_rs_codec::{MlsDecode, MlsEncode, MlsSize};
+    use mls_rs_core::extension::MlsExtension;
+
+    use super::*;
+
+    pub const TEST_EXTENSION_TYPE: u16 = 42;
+
+    #[derive(MlsSize, MlsEncode, MlsDecode, Clone, Debug, PartialEq)]
+    pub(crate) struct TestExtension {
+        pub(crate) foo: u8,
+    }
+
+    impl From<u8> for TestExtension {
+        fn from(value: u8) -> Self {
+            Self { foo: value }
+        }
+    }
+
+    impl MlsExtension for TestExtension {
+        type SerializationError = Infallible;
+
+        type DeserializationError = Infallible;
+
+        fn extension_type() -> ExtensionType {
+            ExtensionType::from(TEST_EXTENSION_TYPE)
+        }
+
+        fn to_bytes(&self) -> Result<Vec<u8>, Self::SerializationError> {
+            Ok([self.foo].to_vec())
+        }
+
+        fn from_bytes(data: &[u8]) -> Result<Self, Self::DeserializationError> {
+            Ok(TestExtension { foo: data[0] })
+        }
+    }
+}
diff --git a/src/extension/built_in.rs b/src/extension/built_in.rs
new file mode 100644
index 0000000..361a112
--- /dev/null
+++ b/src/extension/built_in.rs
@@ -0,0 +1,330 @@
+// 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::{ExtensionType, MlsCodecExtension};
+
+use mls_rs_core::{group::ProposalType, identity::CredentialType};
+
+#[cfg(feature = "by_ref_proposal")]
+use mls_rs_core::{
+    extension::ExtensionList,
+    identity::{IdentityProvider, SigningIdentity},
+    time::MlsTime,
+};
+
+use crate::group::ExportedTree;
+
+use mls_rs_core::crypto::HpkePublicKey;
+
+/// Application specific identifier.
+///
+/// A custom application level identifier that can be optionally stored
+/// within the `leaf_node_extensions` of a group [Member](crate::group::Member).
+#[cfg_attr(
+    all(feature = "ffi", not(test)),
+    safer_ffi_gen::ffi_type(clone, opaque)
+)]
+#[derive(Clone, PartialEq, Eq, MlsSize, MlsEncode, MlsDecode)]
+pub struct ApplicationIdExt {
+    /// Application level identifier presented by this extension.
+    #[mls_codec(with = "mls_rs_codec::byte_vec")]
+    pub identifier: Vec<u8>,
+}
+
+impl Debug for ApplicationIdExt {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.debug_struct("ApplicationIdExt")
+            .field(
+                "identifier",
+                &mls_rs_core::debug::pretty_bytes(&self.identifier),
+            )
+            .finish()
+    }
+}
+
+#[cfg_attr(all(feature = "ffi", not(test)), safer_ffi_gen::safer_ffi_gen)]
+impl ApplicationIdExt {
+    /// Create a new application level identifier extension.
+    pub fn new(identifier: Vec<u8>) -> Self {
+        ApplicationIdExt { identifier }
+    }
+
+    /// Get the application level identifier presented by this extension.
+    #[cfg(feature = "ffi")]
+    pub fn identifier(&self) -> &[u8] {
+        &self.identifier
+    }
+}
+
+impl MlsCodecExtension for ApplicationIdExt {
+    fn extension_type() -> ExtensionType {
+        ExtensionType::APPLICATION_ID
+    }
+}
+
+/// Representation of an MLS ratchet tree.
+///
+/// Used to provide new members
+/// a copy of the current group state in-band.
+#[cfg_attr(
+    all(feature = "ffi", not(test)),
+    safer_ffi_gen::ffi_type(clone, opaque)
+)]
+#[derive(Clone, Debug, PartialEq, MlsSize, MlsEncode, MlsDecode)]
+pub struct RatchetTreeExt {
+    pub tree_data: ExportedTree<'static>,
+}
+
+#[cfg_attr(all(feature = "ffi", not(test)), safer_ffi_gen::safer_ffi_gen)]
+impl RatchetTreeExt {
+    /// Required custom extension types.
+    #[cfg(feature = "ffi")]
+    pub fn tree_data(&self) -> &ExportedTree<'static> {
+        &self.tree_data
+    }
+}
+
+impl MlsCodecExtension for RatchetTreeExt {
+    fn extension_type() -> ExtensionType {
+        ExtensionType::RATCHET_TREE
+    }
+}
+
+/// Require members to have certain capabilities.
+///
+/// Used within a
+/// [Group Context Extensions Proposal](crate::group::proposal::Proposal)
+/// in order to require that all current and future members of a group MUST
+/// support specific extensions, proposals, or credentials.
+///
+/// # Warning
+///
+/// Extension, proposal, and credential types defined by the MLS RFC and
+/// provided are considered required by default and should NOT be used
+/// within this extension.
+#[cfg_attr(
+    all(feature = "ffi", not(test)),
+    safer_ffi_gen::ffi_type(clone, opaque)
+)]
+#[derive(Clone, Debug, PartialEq, Eq, MlsSize, MlsEncode, MlsDecode, Default)]
+pub struct RequiredCapabilitiesExt {
+    pub extensions: Vec<ExtensionType>,
+    pub proposals: Vec<ProposalType>,
+    pub credentials: Vec<CredentialType>,
+}
+
+#[cfg_attr(all(feature = "ffi", not(test)), safer_ffi_gen::safer_ffi_gen)]
+impl RequiredCapabilitiesExt {
+    /// Create a required capabilities extension.
+    pub fn new(
+        extensions: Vec<ExtensionType>,
+        proposals: Vec<ProposalType>,
+        credentials: Vec<CredentialType>,
+    ) -> Self {
+        Self {
+            extensions,
+            proposals,
+            credentials,
+        }
+    }
+
+    /// Required custom extension types.
+    #[cfg(feature = "ffi")]
+    pub fn extensions(&self) -> &[ExtensionType] {
+        &self.extensions
+    }
+
+    /// Required custom proposal types.
+    #[cfg(feature = "ffi")]
+    pub fn proposals(&self) -> &[ProposalType] {
+        &self.proposals
+    }
+
+    /// Required custom credential types.
+    #[cfg(feature = "ffi")]
+    pub fn credentials(&self) -> &[CredentialType] {
+        &self.credentials
+    }
+}
+
+impl MlsCodecExtension for RequiredCapabilitiesExt {
+    fn extension_type() -> ExtensionType {
+        ExtensionType::REQUIRED_CAPABILITIES
+    }
+}
+
+/// External public key used for [External Commits](crate::Client::commit_external).
+///
+/// This proposal type is optionally provided as part of a
+/// [Group Info](crate::group::Group::group_info_message).
+#[cfg_attr(
+    all(feature = "ffi", not(test)),
+    safer_ffi_gen::ffi_type(clone, opaque)
+)]
+#[derive(Clone, Debug, PartialEq, Eq, MlsSize, MlsEncode, MlsDecode)]
+pub struct ExternalPubExt {
+    /// Public key to be used for an external commit.
+    #[mls_codec(with = "mls_rs_codec::byte_vec")]
+    pub external_pub: HpkePublicKey,
+}
+
+#[cfg_attr(all(feature = "ffi", not(test)), safer_ffi_gen::safer_ffi_gen)]
+impl ExternalPubExt {
+    /// Get the public key to be used for an external commit.
+    #[cfg(feature = "ffi")]
+    pub fn external_pub(&self) -> &HpkePublicKey {
+        &self.external_pub
+    }
+}
+
+impl MlsCodecExtension for ExternalPubExt {
+    fn extension_type() -> ExtensionType {
+        ExtensionType::EXTERNAL_PUB
+    }
+}
+
+/// Enable proposals by an [ExternalClient](crate::external_client::ExternalClient).
+#[cfg(feature = "by_ref_proposal")]
+#[cfg_attr(
+    all(feature = "ffi", not(test)),
+    safer_ffi_gen::ffi_type(clone, opaque)
+)]
+#[derive(Clone, Debug, PartialEq, Eq, MlsSize, MlsEncode, MlsDecode)]
+#[non_exhaustive]
+pub struct ExternalSendersExt {
+    pub allowed_senders: Vec<SigningIdentity>,
+}
+
+#[cfg(feature = "by_ref_proposal")]
+#[cfg_attr(all(feature = "ffi", not(test)), safer_ffi_gen::safer_ffi_gen)]
+impl ExternalSendersExt {
+    pub fn new(allowed_senders: Vec<SigningIdentity>) -> Self {
+        Self { allowed_senders }
+    }
+
+    #[cfg(feature = "ffi")]
+    pub fn allowed_senders(&self) -> &[SigningIdentity] {
+        &self.allowed_senders
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(crate) async fn verify_all<I: IdentityProvider>(
+        &self,
+        provider: &I,
+        timestamp: Option<MlsTime>,
+        group_context_extensions: &ExtensionList,
+    ) -> Result<(), I::Error> {
+        for id in self.allowed_senders.iter() {
+            provider
+                .validate_external_sender(id, timestamp, Some(group_context_extensions))
+                .await?;
+        }
+
+        Ok(())
+    }
+}
+
+#[cfg(feature = "by_ref_proposal")]
+impl MlsCodecExtension for ExternalSendersExt {
+    fn extension_type() -> ExtensionType {
+        ExtensionType::EXTERNAL_SENDERS
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    use crate::tree_kem::node::NodeVec;
+    #[cfg(feature = "by_ref_proposal")]
+    use crate::{
+        client::test_utils::TEST_CIPHER_SUITE, identity::test_utils::get_test_signing_identity,
+    };
+
+    use mls_rs_core::extension::MlsExtension;
+
+    use mls_rs_core::identity::BasicCredential;
+
+    use alloc::vec;
+
+    #[cfg(target_arch = "wasm32")]
+    use wasm_bindgen_test::wasm_bindgen_test as test;
+
+    #[test]
+    fn test_application_id_extension() {
+        let test_id = vec![0u8; 32];
+        let test_extension = ApplicationIdExt {
+            identifier: test_id.clone(),
+        };
+
+        let as_extension = test_extension.into_extension().unwrap();
+
+        assert_eq!(as_extension.extension_type, ExtensionType::APPLICATION_ID);
+
+        let restored = ApplicationIdExt::from_extension(&as_extension).unwrap();
+        assert_eq!(restored.identifier, test_id);
+    }
+
+    #[test]
+    fn test_ratchet_tree() {
+        let ext = RatchetTreeExt {
+            tree_data: ExportedTree::new(NodeVec::from(vec![None, None])),
+        };
+
+        let as_extension = ext.clone().into_extension().unwrap();
+        assert_eq!(as_extension.extension_type, ExtensionType::RATCHET_TREE);
+
+        let restored = RatchetTreeExt::from_extension(&as_extension).unwrap();
+        assert_eq!(ext, restored)
+    }
+
+    #[test]
+    fn test_required_capabilities() {
+        let ext = RequiredCapabilitiesExt {
+            extensions: vec![0.into(), 1.into()],
+            proposals: vec![42.into(), 43.into()],
+            credentials: vec![BasicCredential::credential_type()],
+        };
+
+        let as_extension = ext.clone().into_extension().unwrap();
+
+        assert_eq!(
+            as_extension.extension_type,
+            ExtensionType::REQUIRED_CAPABILITIES
+        );
+
+        let restored = RequiredCapabilitiesExt::from_extension(&as_extension).unwrap();
+        assert_eq!(ext, restored)
+    }
+
+    #[cfg(feature = "by_ref_proposal")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_external_senders() {
+        let identity = get_test_signing_identity(TEST_CIPHER_SUITE, &[1]).await.0;
+        let ext = ExternalSendersExt::new(vec![identity]);
+
+        let as_extension = ext.clone().into_extension().unwrap();
+
+        assert_eq!(as_extension.extension_type, ExtensionType::EXTERNAL_SENDERS);
+
+        let restored = ExternalSendersExt::from_extension(&as_extension).unwrap();
+        assert_eq!(ext, restored)
+    }
+
+    #[test]
+    fn test_external_pub() {
+        let ext = ExternalPubExt {
+            external_pub: vec![0, 1, 2, 3].into(),
+        };
+
+        let as_extension = ext.clone().into_extension().unwrap();
+        assert_eq!(as_extension.extension_type, ExtensionType::EXTERNAL_PUB);
+
+        let restored = ExternalPubExt::from_extension(&as_extension).unwrap();
+        assert_eq!(ext, restored)
+    }
+}
diff --git a/src/external_client.rs b/src/external_client.rs
new file mode 100644
index 0000000..0c882ac
--- /dev/null
+++ b/src/external_client.rs
@@ -0,0 +1,142 @@
+// 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::{framing::MlsMessage, message_processor::validate_key_package, ExportedTree},
+    KeyPackage,
+};
+
+pub mod builder;
+mod config;
+mod group;
+
+pub(crate) use config::ExternalClientConfig;
+use mls_rs_core::{
+    crypto::{CryptoProvider, SignatureSecretKey},
+    identity::SigningIdentity,
+};
+
+use builder::{ExternalBaseConfig, ExternalClientBuilder};
+
+pub use group::{ExternalGroup, ExternalReceivedMessage, ExternalSnapshot};
+
+/// A client capable of observing a group's state without having
+/// private keys required to read content.
+///
+/// This structure is useful when an application is sending
+/// plaintext control messages in order to allow a central server
+/// to facilitate communication between users.
+///
+/// # Warning
+///
+/// This structure will only be able to observe groups that were
+/// created by clients that have the `encrypt_control_messages`
+/// option returned by [`MlsRules::encryption_options`](`crate::MlsRules::encryption_options`)
+/// set to `false`. Any control messages that are sent encrypted
+/// over the wire will break the ability of this client to track
+/// the resulting group state.
+pub struct ExternalClient<C> {
+    config: C,
+    signing_data: Option<(SignatureSecretKey, SigningIdentity)>,
+}
+
+impl ExternalClient<()> {
+    pub fn builder() -> ExternalClientBuilder<ExternalBaseConfig> {
+        ExternalClientBuilder::new()
+    }
+}
+
+impl<C> ExternalClient<C>
+where
+    C: ExternalClientConfig + Clone,
+{
+    pub(crate) fn new(
+        config: C,
+        signing_data: Option<(SignatureSecretKey, SigningIdentity)>,
+    ) -> Self {
+        Self {
+            config,
+            signing_data,
+        }
+    }
+
+    /// Begin observing a group based on a GroupInfo message created by
+    /// [Group::group_info_message](crate::group::Group::group_info_message)
+    ///
+    ///`tree_data` is required to be provided out of band if the client that
+    /// created GroupInfo message did not did not use the `ratchet_tree_extension`
+    /// according to [`MlsRules::commit_options`](crate::MlsRules::commit_options)
+    /// at the time the welcome message
+    /// was created. `tree_data` can be exported from a group using the
+    /// [export tree function](crate::group::Group::export_tree).
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn observe_group(
+        &self,
+        group_info: MlsMessage,
+        tree_data: Option<ExportedTree<'_>>,
+    ) -> Result<ExternalGroup<C>, MlsError> {
+        ExternalGroup::join(
+            self.config.clone(),
+            self.signing_data.clone(),
+            group_info,
+            tree_data,
+        )
+        .await
+    }
+
+    /// Load an existing observed group by loading a snapshot that was
+    /// generated by
+    /// [ExternalGroup::snapshot](self::ExternalGroup::snapshot).
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn load_group(
+        &self,
+        snapshot: ExternalSnapshot,
+    ) -> Result<ExternalGroup<C>, MlsError> {
+        ExternalGroup::from_snapshot(self.config.clone(), snapshot).await
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn validate_key_package(
+        &self,
+        key_package: MlsMessage,
+    ) -> Result<KeyPackage, MlsError> {
+        let version = key_package.version;
+
+        let key_package = key_package
+            .into_key_package()
+            .ok_or(MlsError::UnexpectedMessageType)?;
+
+        let cs = self
+            .config
+            .crypto_provider()
+            .cipher_suite_provider(key_package.cipher_suite)
+            .ok_or(MlsError::UnsupportedCipherSuite(key_package.cipher_suite))?;
+
+        let id = self.config.identity_provider();
+
+        validate_key_package(&key_package, version, &cs, &id).await?;
+
+        Ok(key_package)
+    }
+}
+
+#[cfg(test)]
+pub(crate) mod tests_utils {
+    use crate::{
+        client::test_utils::{TEST_CIPHER_SUITE, TEST_PROTOCOL_VERSION},
+        key_package::test_utils::test_key_package_message,
+    };
+
+    pub use super::builder::test_utils::*;
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn external_client_can_validate_key_package() {
+        let kp = test_key_package_message(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, "john").await;
+        let server = TestExternalClientBuilder::new_for_test().build();
+        let validated_kp = server.validate_key_package(kp.clone()).await.unwrap();
+
+        assert_eq!(kp.into_key_package().unwrap(), validated_kp);
+    }
+}
diff --git a/src/external_client/builder.rs b/src/external_client/builder.rs
new file mode 100644
index 0000000..04c9768
--- /dev/null
+++ b/src/external_client/builder.rs
@@ -0,0 +1,602 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// Copyright by contributors to this project.
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+
+//! Definitions to build an [`ExternalClient`].
+//!
+//! See [`ExternalClientBuilder`].
+
+use crate::{
+    crypto::SignaturePublicKey,
+    extension::ExtensionType,
+    external_client::{ExternalClient, ExternalClientConfig},
+    group::{
+        mls_rules::{DefaultMlsRules, MlsRules},
+        proposal::ProposalType,
+    },
+    identity::CredentialType,
+    protocol_version::ProtocolVersion,
+    tree_kem::Capabilities,
+    CryptoProvider, Sealed,
+};
+use std::{
+    collections::HashMap,
+    fmt::{self, Debug},
+};
+
+/// Base client configuration type when instantiating `ExternalClientBuilder`
+pub type ExternalBaseConfig = Config<Missing, DefaultMlsRules, Missing>;
+
+/// Builder for [`ExternalClient`]
+///
+/// This is returned by [`ExternalClient::builder`] and allows to tweak settings the
+/// `ExternalClient` will use. At a minimum, the builder must be told the [`CryptoProvider`]
+/// and [`IdentityProvider`] to use. Other settings have default values. This
+/// means that the following methods must be called before [`ExternalClientBuilder::build`]:
+///
+/// - To specify the [`CryptoProvider`]: [`ExternalClientBuilder::crypto_provider`]
+/// - To specify the [`IdentityProvider`]: [`ExternalClientBuilder::identity_provider`]
+///
+/// # Example
+///
+/// ```
+/// use mls_rs::{
+///     external_client::ExternalClient,
+///     identity::basic::BasicIdentityProvider,
+/// };
+///
+/// use mls_rs_crypto_openssl::OpensslCryptoProvider;
+///
+/// let _client = ExternalClient::builder()
+///     .crypto_provider(OpensslCryptoProvider::default())
+///     .identity_provider(BasicIdentityProvider::new())
+///     .build();
+/// ```
+///
+/// # Spelling out an `ExternalClient` type
+///
+/// There are two main ways to spell out an `ExternalClient` type if needed (e.g. function return type).
+///
+/// The first option uses `impl MlsConfig`:
+/// ```
+/// use mls_rs::{
+///     external_client::{ExternalClient, builder::MlsConfig},
+///     identity::basic::BasicIdentityProvider,
+/// };
+///
+/// use mls_rs_crypto_openssl::OpensslCryptoProvider;
+///
+/// fn make_client() -> ExternalClient<impl MlsConfig> {
+///     ExternalClient::builder()
+///         .crypto_provider(OpensslCryptoProvider::default())
+///         .identity_provider(BasicIdentityProvider::new())
+///         .build()
+/// }
+///```
+///
+/// The second option is more verbose and consists in writing the full `ExternalClient` type:
+/// ```
+/// use mls_rs::{
+///     external_client::{ExternalClient, builder::{ExternalBaseConfig, WithIdentityProvider, WithCryptoProvider}},
+///     identity::basic::BasicIdentityProvider,
+/// };
+///
+/// use mls_rs_crypto_openssl::OpensslCryptoProvider;
+///
+/// type MlsClient = ExternalClient<WithIdentityProvider<
+///     BasicIdentityProvider,
+///     WithCryptoProvider<OpensslCryptoProvider, ExternalBaseConfig>,
+/// >>;
+///
+/// fn make_client_2() -> MlsClient {
+///     ExternalClient::builder()
+///         .crypto_provider(OpensslCryptoProvider::new())
+///         .identity_provider(BasicIdentityProvider::new())
+///         .build()
+/// }
+///
+/// ```
+#[derive(Debug)]
+pub struct ExternalClientBuilder<C>(C);
+
+impl Default for ExternalClientBuilder<ExternalBaseConfig> {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+impl ExternalClientBuilder<ExternalBaseConfig> {
+    pub fn new() -> Self {
+        Self(Config(ConfigInner {
+            settings: Default::default(),
+            identity_provider: Missing,
+            mls_rules: DefaultMlsRules::new(),
+            crypto_provider: Missing,
+            signing_data: None,
+        }))
+    }
+}
+
+impl<C: IntoConfig> ExternalClientBuilder<C> {
+    /// Add an extension type to the list of extension types supported by the client.
+    pub fn extension_type(
+        self,
+        type_: ExtensionType,
+    ) -> ExternalClientBuilder<IntoConfigOutput<C>> {
+        self.extension_types(Some(type_))
+    }
+
+    /// Add multiple extension types to the list of extension types supported by the client.
+    pub fn extension_types<I>(self, types: I) -> ExternalClientBuilder<IntoConfigOutput<C>>
+    where
+        I: IntoIterator<Item = ExtensionType>,
+    {
+        let mut c = self.0.into_config();
+        c.0.settings.extension_types.extend(types);
+        ExternalClientBuilder(c)
+    }
+
+    /// Add a custom proposal type to the list of proposals types supported by the client.
+    pub fn custom_proposal_type(
+        self,
+        type_: ProposalType,
+    ) -> ExternalClientBuilder<IntoConfigOutput<C>> {
+        self.custom_proposal_types(Some(type_))
+    }
+
+    /// Add multiple custom proposal types to the list of proposal types supported by the client.
+    pub fn custom_proposal_types<I>(self, types: I) -> ExternalClientBuilder<IntoConfigOutput<C>>
+    where
+        I: IntoIterator<Item = ProposalType>,
+    {
+        let mut c = self.0.into_config();
+        c.0.settings.custom_proposal_types.extend(types);
+        ExternalClientBuilder(c)
+    }
+
+    /// Add a protocol version to the list of protocol versions supported by the client.
+    ///
+    /// If no protocol version is explicitly added, the client will support all protocol versions
+    /// supported by this crate.
+    pub fn protocol_version(
+        self,
+        version: ProtocolVersion,
+    ) -> ExternalClientBuilder<IntoConfigOutput<C>> {
+        self.protocol_versions(Some(version))
+    }
+
+    /// Add multiple protocol versions to the list of protocol versions supported by the client.
+    ///
+    /// If no protocol version is explicitly added, the client will support all protocol versions
+    /// supported by this crate.
+    pub fn protocol_versions<I>(self, versions: I) -> ExternalClientBuilder<IntoConfigOutput<C>>
+    where
+        I: IntoIterator<Item = ProtocolVersion>,
+    {
+        let mut c = self.0.into_config();
+        c.0.settings.protocol_versions.extend(versions);
+        ExternalClientBuilder(c)
+    }
+
+    /// Add an external signing key to be used by the client.
+    pub fn external_signing_key(
+        self,
+        id: Vec<u8>,
+        key: SignaturePublicKey,
+    ) -> ExternalClientBuilder<IntoConfigOutput<C>> {
+        let mut c = self.0.into_config();
+        c.0.settings.external_signing_keys.insert(id, key);
+        ExternalClientBuilder(c)
+    }
+
+    /// Specify the number of epochs before the current one to keep.
+    ///
+    /// By default, all epochs are kept.
+    pub fn max_epoch_jitter(self, max_jitter: u64) -> ExternalClientBuilder<IntoConfigOutput<C>> {
+        let mut c = self.0.into_config();
+        c.0.settings.max_epoch_jitter = Some(max_jitter);
+        ExternalClientBuilder(c)
+    }
+
+    /// Specify whether processed proposals should be cached by the external group. In case they
+    /// are not cached by the group, they should be cached externally and inserted using
+    /// `ExternalGroup::insert_proposal` before processing the next commit.
+    pub fn cache_proposals(
+        self,
+        cache_proposals: bool,
+    ) -> ExternalClientBuilder<IntoConfigOutput<C>> {
+        let mut c = self.0.into_config();
+        c.0.settings.cache_proposals = cache_proposals;
+        ExternalClientBuilder(c)
+    }
+
+    /// Set the identity validator to be used by the client.
+    pub fn identity_provider<I>(
+        self,
+        identity_provider: I,
+    ) -> ExternalClientBuilder<WithIdentityProvider<I, C>>
+    where
+        I: IdentityProvider,
+    {
+        let Config(c) = self.0.into_config();
+        ExternalClientBuilder(Config(ConfigInner {
+            settings: c.settings,
+            identity_provider,
+            mls_rules: c.mls_rules,
+            crypto_provider: c.crypto_provider,
+            signing_data: c.signing_data,
+        }))
+    }
+
+    /// Set the crypto provider to be used by the client.
+    ///
+    // TODO add a comment once we have a default provider
+    pub fn crypto_provider<Cp>(
+        self,
+        crypto_provider: Cp,
+    ) -> ExternalClientBuilder<WithCryptoProvider<Cp, C>>
+    where
+        Cp: CryptoProvider,
+    {
+        let Config(c) = self.0.into_config();
+        ExternalClientBuilder(Config(ConfigInner {
+            settings: c.settings,
+            identity_provider: c.identity_provider,
+            mls_rules: c.mls_rules,
+            crypto_provider,
+            signing_data: c.signing_data,
+        }))
+    }
+
+    /// Set the user-defined proposal rules to be used by the client.
+    ///
+    /// User-defined rules are used when sending and receiving commits before
+    /// enforcing general MLS protocol rules. If the rule set returns an error when
+    /// receiving a commit, the entire commit is considered invalid. If the
+    /// rule set would return an error when sending a commit, individual proposals
+    /// may be filtered out to compensate.
+    pub fn mls_rules<Pr>(self, mls_rules: Pr) -> ExternalClientBuilder<WithMlsRules<Pr, C>>
+    where
+        Pr: MlsRules,
+    {
+        let Config(c) = self.0.into_config();
+        ExternalClientBuilder(Config(ConfigInner {
+            settings: c.settings,
+            identity_provider: c.identity_provider,
+            mls_rules,
+            crypto_provider: c.crypto_provider,
+            signing_data: c.signing_data,
+        }))
+    }
+
+    /// Set the signature secret key used by the client to send external proposals.
+    pub fn signer(
+        self,
+        signer: SignatureSecretKey,
+        signing_identity: SigningIdentity,
+    ) -> ExternalClientBuilder<IntoConfigOutput<C>> {
+        let mut c = self.0.into_config();
+        c.0.signing_data = Some((signer, signing_identity));
+        ExternalClientBuilder(c)
+    }
+}
+
+impl<C: IntoConfig> ExternalClientBuilder<C>
+where
+    C::IdentityProvider: IdentityProvider + Clone,
+    C::MlsRules: MlsRules + Clone,
+    C::CryptoProvider: CryptoProvider + Clone,
+{
+    pub(crate) fn build_config(self) -> IntoConfigOutput<C> {
+        let mut c = self.0.into_config();
+
+        if c.0.settings.protocol_versions.is_empty() {
+            c.0.settings.protocol_versions = ProtocolVersion::all().collect();
+        }
+
+        c
+    }
+
+    /// Build an external client.
+    ///
+    /// See [`ExternalClientBuilder`] documentation if the return type of this function needs to be
+    /// spelled out.
+    pub fn build(self) -> ExternalClient<IntoConfigOutput<C>> {
+        let mut c = self.build_config();
+        let signing_data = c.0.signing_data.take();
+        ExternalClient::new(c, signing_data)
+    }
+}
+
+/// Marker type for required `ExternalClientBuilder` services that have not been specified yet.
+#[derive(Debug)]
+pub struct Missing;
+
+/// Change the identity validator used by a client configuration.
+///
+/// See [`ExternalClientBuilder::identity_provider`].
+pub type WithIdentityProvider<I, C> =
+    Config<I, <C as IntoConfig>::MlsRules, <C as IntoConfig>::CryptoProvider>;
+
+/// Change the proposal filter used by a client configuration.
+///
+/// See [`ExternalClientBuilder::mls_rules`].
+pub type WithMlsRules<Pr, C> =
+    Config<<C as IntoConfig>::IdentityProvider, Pr, <C as IntoConfig>::CryptoProvider>;
+
+/// Change the crypto provider used by a client configuration.
+///
+/// See [`ExternalClientBuilder::crypto_provider`].
+pub type WithCryptoProvider<Cp, C> =
+    Config<<C as IntoConfig>::IdentityProvider, <C as IntoConfig>::MlsRules, Cp>;
+
+/// Helper alias for `Config`.
+pub type IntoConfigOutput<C> = Config<
+    <C as IntoConfig>::IdentityProvider,
+    <C as IntoConfig>::MlsRules,
+    <C as IntoConfig>::CryptoProvider,
+>;
+
+impl<Ip, Pr, Cp> ExternalClientConfig for ConfigInner<Ip, Pr, Cp>
+where
+    Ip: IdentityProvider + Clone,
+    Pr: MlsRules + Clone,
+    Cp: CryptoProvider + Clone,
+{
+    type IdentityProvider = Ip;
+    type MlsRules = Pr;
+    type CryptoProvider = Cp;
+
+    fn supported_extensions(&self) -> Vec<ExtensionType> {
+        self.settings.extension_types.clone()
+    }
+
+    fn supported_protocol_versions(&self) -> Vec<ProtocolVersion> {
+        self.settings.protocol_versions.clone()
+    }
+
+    fn identity_provider(&self) -> Self::IdentityProvider {
+        self.identity_provider.clone()
+    }
+
+    fn crypto_provider(&self) -> Self::CryptoProvider {
+        self.crypto_provider.clone()
+    }
+
+    fn external_signing_key(&self, external_key_id: &[u8]) -> Option<SignaturePublicKey> {
+        self.settings
+            .external_signing_keys
+            .get(external_key_id)
+            .cloned()
+    }
+
+    fn mls_rules(&self) -> Self::MlsRules {
+        self.mls_rules.clone()
+    }
+
+    fn max_epoch_jitter(&self) -> Option<u64> {
+        self.settings.max_epoch_jitter
+    }
+
+    fn cache_proposals(&self) -> bool {
+        self.settings.cache_proposals
+    }
+
+    fn supported_custom_proposals(&self) -> Vec<ProposalType> {
+        self.settings.custom_proposal_types.clone()
+    }
+}
+
+impl<Ip, Mpf, Cp> Sealed for Config<Ip, Mpf, Cp> {}
+
+impl<Ip, Pr, Cp> MlsConfig for Config<Ip, Pr, Cp>
+where
+    Ip: IdentityProvider + Clone,
+    Pr: MlsRules + Clone,
+    Cp: CryptoProvider + Clone,
+{
+    type Output = ConfigInner<Ip, Pr, Cp>;
+
+    fn get(&self) -> &Self::Output {
+        &self.0
+    }
+}
+
+/// Helper trait to allow consuming crates to easily write an external client type as
+/// `ExternalClient<impl MlsConfig>`
+///
+/// It is not meant to be implemented by consuming crates. `T: MlsConfig` implies
+/// `T: ExternalClientConfig`.
+pub trait MlsConfig: Send + Sync + Clone + Sealed {
+    #[doc(hidden)]
+    type Output: ExternalClientConfig;
+
+    #[doc(hidden)]
+    fn get(&self) -> &Self::Output;
+}
+
+/// Blanket implementation so that `T: MlsConfig` implies `T: ExternalClientConfig`
+impl<T: MlsConfig> ExternalClientConfig for T {
+    type IdentityProvider = <T::Output as ExternalClientConfig>::IdentityProvider;
+    type MlsRules = <T::Output as ExternalClientConfig>::MlsRules;
+    type CryptoProvider = <T::Output as ExternalClientConfig>::CryptoProvider;
+
+    fn supported_extensions(&self) -> Vec<ExtensionType> {
+        self.get().supported_extensions()
+    }
+
+    fn supported_protocol_versions(&self) -> Vec<ProtocolVersion> {
+        self.get().supported_protocol_versions()
+    }
+
+    fn supported_custom_proposals(&self) -> Vec<ProposalType> {
+        self.get().supported_custom_proposals()
+    }
+
+    fn identity_provider(&self) -> Self::IdentityProvider {
+        self.get().identity_provider()
+    }
+
+    fn crypto_provider(&self) -> Self::CryptoProvider {
+        self.get().crypto_provider()
+    }
+
+    fn external_signing_key(&self, external_key_id: &[u8]) -> Option<SignaturePublicKey> {
+        self.get().external_signing_key(external_key_id)
+    }
+
+    fn mls_rules(&self) -> Self::MlsRules {
+        self.get().mls_rules()
+    }
+
+    fn cache_proposals(&self) -> bool {
+        self.get().cache_proposals()
+    }
+
+    fn max_epoch_jitter(&self) -> Option<u64> {
+        self.get().max_epoch_jitter()
+    }
+
+    fn capabilities(&self) -> Capabilities {
+        self.get().capabilities()
+    }
+
+    fn version_supported(&self, version: ProtocolVersion) -> bool {
+        self.get().version_supported(version)
+    }
+
+    fn supported_credentials(&self) -> Vec<CredentialType> {
+        self.get().supported_credentials()
+    }
+}
+
+#[derive(Clone)]
+pub(crate) struct Settings {
+    pub(crate) extension_types: Vec<ExtensionType>,
+    pub(crate) custom_proposal_types: Vec<ProposalType>,
+    pub(crate) protocol_versions: Vec<ProtocolVersion>,
+    pub(crate) external_signing_keys: HashMap<Vec<u8>, SignaturePublicKey>,
+    pub(crate) max_epoch_jitter: Option<u64>,
+    pub(crate) cache_proposals: bool,
+}
+
+impl Debug for Settings {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.debug_struct("Settings")
+            .field("extension_types", &self.extension_types)
+            .field("custom_proposal_types", &self.custom_proposal_types)
+            .field("protocol_versions", &self.protocol_versions)
+            .field(
+                "external_signing_keys",
+                &mls_rs_core::debug::pretty_with(|f| {
+                    f.debug_map()
+                        .entries(
+                            self.external_signing_keys
+                                .iter()
+                                .map(|(k, v)| (mls_rs_core::debug::pretty_bytes(k), v)),
+                        )
+                        .finish()
+                }),
+            )
+            .field("max_epoch_jitter", &self.max_epoch_jitter)
+            .field("cache_proposals", &self.cache_proposals)
+            .finish()
+    }
+}
+
+impl Default for Settings {
+    fn default() -> Self {
+        Self {
+            cache_proposals: true,
+            extension_types: vec![],
+            protocol_versions: vec![],
+            external_signing_keys: Default::default(),
+            max_epoch_jitter: None,
+            custom_proposal_types: vec![],
+        }
+    }
+}
+
+/// Definitions meant to be private that are inaccessible outside this crate. They need to be marked
+/// `pub` because they appear in public definitions.
+mod private {
+    use mls_rs_core::{crypto::SignatureSecretKey, identity::SigningIdentity};
+
+    use super::{IntoConfigOutput, Settings};
+
+    #[derive(Clone, Debug)]
+    pub struct Config<Ip, Pr, Cp>(pub(crate) ConfigInner<Ip, Pr, Cp>);
+
+    #[derive(Clone, Debug)]
+    pub struct ConfigInner<Ip, Mpf, Cp> {
+        pub(crate) settings: Settings,
+        pub(crate) identity_provider: Ip,
+        pub(crate) mls_rules: Mpf,
+        pub(crate) crypto_provider: Cp,
+        pub(crate) signing_data: Option<(SignatureSecretKey, SigningIdentity)>,
+    }
+
+    pub trait IntoConfig {
+        type IdentityProvider;
+        type MlsRules;
+        type CryptoProvider;
+
+        fn into_config(self) -> IntoConfigOutput<Self>;
+    }
+
+    impl<Ip, Pr, Cp> IntoConfig for Config<Ip, Pr, Cp> {
+        type IdentityProvider = Ip;
+        type MlsRules = Pr;
+        type CryptoProvider = Cp;
+
+        fn into_config(self) -> Self {
+            self
+        }
+    }
+}
+
+use mls_rs_core::{
+    crypto::SignatureSecretKey,
+    identity::{IdentityProvider, SigningIdentity},
+};
+use private::{Config, ConfigInner, IntoConfig};
+
+#[cfg(test)]
+pub(crate) mod test_utils {
+    use crate::{
+        cipher_suite::CipherSuite, crypto::test_utils::TestCryptoProvider,
+        identity::basic::BasicIdentityProvider,
+    };
+
+    use super::{
+        ExternalBaseConfig, ExternalClientBuilder, WithCryptoProvider, WithIdentityProvider,
+    };
+
+    pub type TestExternalClientConfig = WithIdentityProvider<
+        BasicIdentityProvider,
+        WithCryptoProvider<TestCryptoProvider, ExternalBaseConfig>,
+    >;
+
+    pub type TestExternalClientBuilder = ExternalClientBuilder<TestExternalClientConfig>;
+
+    impl TestExternalClientBuilder {
+        pub fn new_for_test() -> Self {
+            ExternalClientBuilder::new()
+                .crypto_provider(TestCryptoProvider::default())
+                .identity_provider(BasicIdentityProvider::new())
+        }
+
+        pub fn new_for_test_disabling_cipher_suite(cipher_suite: CipherSuite) -> Self {
+            let crypto_provider = TestCryptoProvider::with_enabled_cipher_suites(
+                TestCryptoProvider::all_supported_cipher_suites()
+                    .into_iter()
+                    .filter(|cs| cs != &cipher_suite)
+                    .collect(),
+            );
+
+            ExternalClientBuilder::new()
+                .crypto_provider(crypto_provider)
+                .identity_provider(BasicIdentityProvider::new())
+        }
+    }
+}
diff --git a/src/external_client/config.rs b/src/external_client/config.rs
new file mode 100644
index 0000000..649be99
--- /dev/null
+++ b/src/external_client/config.rs
@@ -0,0 +1,54 @@
+// 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::identity::IdentityProvider;
+
+use crate::{
+    crypto::SignaturePublicKey,
+    extension::ExtensionType,
+    group::{mls_rules::MlsRules, proposal::ProposalType},
+    identity::CredentialType,
+    protocol_version::ProtocolVersion,
+    tree_kem::Capabilities,
+    CryptoProvider,
+};
+
+pub trait ExternalClientConfig: Send + Sync + Clone {
+    type IdentityProvider: IdentityProvider + Clone;
+    type MlsRules: MlsRules + Clone;
+    type CryptoProvider: CryptoProvider;
+
+    fn supported_extensions(&self) -> Vec<ExtensionType>;
+    fn supported_custom_proposals(&self) -> Vec<ProposalType>;
+    fn supported_protocol_versions(&self) -> Vec<ProtocolVersion>;
+    fn identity_provider(&self) -> Self::IdentityProvider;
+    fn crypto_provider(&self) -> Self::CryptoProvider;
+    fn external_signing_key(&self, external_key_id: &[u8]) -> Option<SignaturePublicKey>;
+
+    fn mls_rules(&self) -> Self::MlsRules;
+
+    fn cache_proposals(&self) -> bool;
+
+    fn max_epoch_jitter(&self) -> Option<u64> {
+        None
+    }
+
+    fn capabilities(&self) -> Capabilities {
+        Capabilities {
+            protocol_versions: self.supported_protocol_versions(),
+            cipher_suites: self.crypto_provider().supported_cipher_suites(),
+            extensions: self.supported_extensions(),
+            proposals: self.supported_custom_proposals(),
+            credentials: self.supported_credentials(),
+        }
+    }
+
+    fn version_supported(&self, version: ProtocolVersion) -> bool {
+        self.supported_protocol_versions().contains(&version)
+    }
+
+    fn supported_credentials(&self) -> Vec<CredentialType> {
+        self.identity_provider().supported_types()
+    }
+}
diff --git a/src/external_client/group.rs b/src/external_client/group.rs
new file mode 100644
index 0000000..8939948
--- /dev/null
+++ b/src/external_client/group.rs
@@ -0,0 +1,1354 @@
+// 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_codec::{MlsDecode, MlsEncode, MlsSize};
+use mls_rs_core::{
+    crypto::SignatureSecretKey, error::IntoAnyError, extension::ExtensionList, group::Member,
+    identity::IdentityProvider,
+};
+
+use crate::{
+    cipher_suite::CipherSuite,
+    client::MlsError,
+    external_client::ExternalClientConfig,
+    group::{
+        cipher_suite_provider,
+        confirmation_tag::ConfirmationTag,
+        framing::PublicMessage,
+        member_from_leaf_node,
+        message_processor::{
+            ApplicationMessageDescription, CommitMessageDescription, EventOrContent,
+            MessageProcessor, ProposalMessageDescription, ProvisionalState,
+        },
+        snapshot::RawGroupState,
+        state::GroupState,
+        transcript_hash::InterimTranscriptHash,
+        validate_group_info_joiner, ContentType, ExportedTree, GroupContext, GroupInfo, Roster,
+        Welcome,
+    },
+    identity::SigningIdentity,
+    protocol_version::ProtocolVersion,
+    psk::AlwaysFoundPskStorage,
+    tree_kem::{node::LeafIndex, path_secret::PathSecret, TreeKemPrivate},
+    CryptoProvider, KeyPackage, MlsMessage,
+};
+
+#[cfg(feature = "by_ref_proposal")]
+use crate::{
+    group::{
+        framing::{Content, MlsMessagePayload},
+        message_processor::CachedProposal,
+        message_signature::AuthenticatedContent,
+        proposal::Proposal,
+        proposal_ref::ProposalRef,
+        Sender,
+    },
+    WireFormat,
+};
+
+#[cfg(all(feature = "by_ref_proposal", feature = "custom_proposal"))]
+use crate::group::proposal::CustomProposal;
+
+#[cfg(feature = "by_ref_proposal")]
+use mls_rs_core::{crypto::CipherSuiteProvider, psk::ExternalPskId};
+
+#[cfg(feature = "by_ref_proposal")]
+use crate::{
+    extension::ExternalSendersExt,
+    group::proposal::{AddProposal, ReInitProposal, RemoveProposal},
+};
+
+#[cfg(all(feature = "by_ref_proposal", feature = "psk"))]
+use crate::{
+    group::proposal::PreSharedKeyProposal,
+    psk::{
+        JustPreSharedKeyID, PreSharedKeyID, PskGroupId, PskNonce, ResumptionPSKUsage, ResumptionPsk,
+    },
+};
+
+#[cfg(feature = "private_message")]
+use crate::group::framing::PrivateMessage;
+
+use alloc::boxed::Box;
+
+/// The result of processing an [ExternalGroup](ExternalGroup) message using
+/// [process_incoming_message](ExternalGroup::process_incoming_message)
+#[derive(Clone, Debug)]
+#[allow(clippy::large_enum_variant)]
+pub enum ExternalReceivedMessage {
+    /// State update as the result of a successful commit.
+    Commit(CommitMessageDescription),
+    /// Received proposal and its unique identifier.
+    Proposal(ProposalMessageDescription),
+    /// Encrypted message that can not be processed.
+    Ciphertext(ContentType),
+    /// Validated GroupInfo object
+    GroupInfo(GroupInfo),
+    /// Validated welcome message
+    Welcome,
+    /// Validated key package
+    KeyPackage(KeyPackage),
+}
+
+/// A handle to an observed group that can track plaintext control messages
+/// and the resulting group state.
+#[derive(Clone)]
+pub struct ExternalGroup<C>
+where
+    C: ExternalClientConfig,
+{
+    pub(crate) config: C,
+    pub(crate) cipher_suite_provider: <C::CryptoProvider as CryptoProvider>::CipherSuiteProvider,
+    pub(crate) state: GroupState,
+    pub(crate) signing_data: Option<(SignatureSecretKey, SigningIdentity)>,
+}
+
+impl<C: ExternalClientConfig + Clone> ExternalGroup<C> {
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(crate) async fn join(
+        config: C,
+        signing_data: Option<(SignatureSecretKey, SigningIdentity)>,
+        group_info: MlsMessage,
+        tree_data: Option<ExportedTree<'_>>,
+    ) -> Result<Self, MlsError> {
+        let protocol_version = group_info.version;
+
+        if !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_provider = cipher_suite_provider(
+            config.crypto_provider(),
+            group_info.group_context.cipher_suite,
+        )?;
+
+        let public_tree = validate_group_info_joiner(
+            protocol_version,
+            &group_info,
+            tree_data,
+            &config.identity_provider(),
+            &cipher_suite_provider,
+        )
+        .await?;
+
+        let interim_transcript_hash = InterimTranscriptHash::create(
+            &cipher_suite_provider,
+            &group_info.group_context.confirmed_transcript_hash,
+            &group_info.confirmation_tag,
+        )
+        .await?;
+
+        Ok(Self {
+            config,
+            signing_data,
+            state: GroupState::new(
+                group_info.group_context,
+                public_tree,
+                interim_transcript_hash,
+                group_info.confirmation_tag,
+            ),
+            cipher_suite_provider,
+        })
+    }
+
+    /// Process a message that was sent to the group.
+    ///
+    /// * Proposals will be stored in the group state and processed by the
+    /// same rules as a standard group.
+    ///
+    /// * Commits will result in the same outcome as a standard group.
+    /// However, the integrity of the resulting group state can only be partially
+    /// verified, since the external group does have access to the group
+    /// secrets required to do a complete check.
+    ///
+    /// * Application messages are always encrypted so they result in a no-op
+    /// that returns [ExternalReceivedMessage::Ciphertext]
+    ///
+    /// # Warning
+    ///
+    /// Processing an encrypted commit or proposal message has the same result
+    /// as processing an encrypted application message. Proper tracking of
+    /// the group state requires that all proposal and commit messages are
+    /// readable.
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn process_incoming_message(
+        &mut self,
+        message: MlsMessage,
+    ) -> Result<ExternalReceivedMessage, MlsError> {
+        MessageProcessor::process_incoming_message(
+            self,
+            message,
+            #[cfg(feature = "by_ref_proposal")]
+            self.config.cache_proposals(),
+        )
+        .await
+    }
+
+    /// Replay a proposal message into the group skipping all validation steps.
+    #[cfg(feature = "by_ref_proposal")]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn insert_proposal_from_message(
+        &mut self,
+        message: MlsMessage,
+    ) -> Result<(), MlsError> {
+        let ptxt = match message.payload {
+            MlsMessagePayload::Plain(p) => Ok(p),
+            _ => Err(MlsError::UnexpectedMessageType),
+        }?;
+
+        let auth_content: AuthenticatedContent = ptxt.into();
+
+        let proposal_ref =
+            ProposalRef::from_content(&self.cipher_suite_provider, &auth_content).await?;
+
+        let sender = auth_content.content.sender;
+
+        let proposal = match auth_content.content.content {
+            Content::Proposal(p) => Ok(*p),
+            _ => Err(MlsError::UnexpectedMessageType),
+        }?;
+
+        self.group_state_mut()
+            .proposals
+            .insert(proposal_ref, proposal, sender);
+
+        Ok(())
+    }
+
+    /// Force insert a proposal directly into the internal state of the group
+    /// with no validation.
+    #[cfg(feature = "by_ref_proposal")]
+    pub fn insert_proposal(&mut self, proposal: CachedProposal) {
+        self.group_state_mut().proposals.insert(
+            proposal.proposal_ref,
+            proposal.proposal,
+            proposal.sender,
+        )
+    }
+
+    /// Create an external proposal to request that a group add a new member
+    ///
+    /// # Warning
+    ///
+    /// In order for the proposal generated by this function to be successfully
+    /// committed, the group needs to have `signing_identity` as an entry
+    /// within an [ExternalSendersExt](crate::extension::built_in::ExternalSendersExt)
+    /// as part of its group context extensions.
+    #[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 key_package = key_package
+            .into_key_package()
+            .ok_or(MlsError::UnexpectedMessageType)?;
+
+        self.propose(
+            Proposal::Add(alloc::boxed::Box::new(AddProposal { key_package })),
+            authenticated_data,
+        )
+        .await
+    }
+
+    /// Create an external proposal to request that a group remove an existing member
+    ///
+    /// # Warning
+    ///
+    /// In order for the proposal generated by this function to be successfully
+    /// committed, the group needs to have `signing_identity` as an entry
+    /// within an [ExternalSendersExt](crate::extension::built_in::ExternalSendersExt)
+    /// as part of its group context extensions.
+    #[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 to_remove = LeafIndex(index);
+
+        // Verify that this leaf is actually in the tree
+        self.group_state().public_tree.get_leaf_node(to_remove)?;
+
+        self.propose(
+            Proposal::Remove(RemoveProposal { to_remove }),
+            authenticated_data,
+        )
+        .await
+    }
+
+    /// Create an external proposal to request that a group inserts an external
+    /// pre shared key into its state.
+    ///
+    /// # Warning
+    ///
+    /// In order for the proposal generated by this function to be successfully
+    /// committed, the group needs to have `signing_identity` as an entry
+    /// within an [ExternalSendersExt](crate::extension::built_in::ExternalSendersExt)
+    /// as part of its group context extensions.
+    #[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.propose(proposal, authenticated_data).await
+    }
+
+    /// Create an external proposal to request that a group adds a pre shared key
+    /// from a previous epoch to the current group state.
+    ///
+    /// # Warning
+    ///
+    /// In order for the proposal generated by this function to be successfully
+    /// committed, the group needs to have `signing_identity` as an entry
+    /// within an [ExternalSendersExt](crate::extension::built_in::ExternalSendersExt)
+    /// as part of its group context extensions.
+    #[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_context().group_id().to_vec()),
+        };
+
+        let proposal = self.psk_proposal(JustPreSharedKeyID::Resumption(key_id))?;
+        self.propose(proposal, authenticated_data).await
+    }
+
+    #[cfg(all(feature = "by_ref_proposal", feature = "psk"))]
+    fn psk_proposal(&self, key_id: JustPreSharedKeyID) -> Result<Proposal, MlsError> {
+        Ok(Proposal::Psk(PreSharedKeyProposal {
+            psk: PreSharedKeyID {
+                key_id,
+                psk_nonce: PskNonce::random(&self.cipher_suite_provider)
+                    .map_err(|e| MlsError::CryptoProviderError(e.into_any_error()))?,
+            },
+        }))
+    }
+
+    /// Create an external proposal to request that a group sets extensions stored in the group
+    /// state.
+    ///
+    /// # Warning
+    ///
+    /// In order for the proposal generated by this function to be successfully
+    /// committed, the group needs to have `signing_identity` as an entry
+    /// within an [ExternalSendersExt](crate::extension::built_in::ExternalSendersExt)
+    /// as part of its group context extensions.
+    #[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 = Proposal::GroupContextExtensions(extensions);
+        self.propose(proposal, authenticated_data).await
+    }
+
+    /// Create an external proposal to request that a group is reinitialized.
+    ///
+    /// # Warning
+    ///
+    /// In order for the proposal generated by this function to be successfully
+    /// committed, the group needs to have `signing_identity` as an entry
+    /// within an [ExternalSendersExt](crate::extension::built_in::ExternalSendersExt)
+    /// as part of its group context extensions.
+    #[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 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()))
+        })?;
+
+        let proposal = Proposal::ReInit(ReInitProposal {
+            group_id,
+            version,
+            cipher_suite,
+            extensions,
+        });
+
+        self.propose(proposal, authenticated_data).await
+    }
+
+    /// Create a custom proposal message.
+    ///
+    /// # Warning
+    ///
+    /// In order for the proposal generated by this function to be successfully
+    /// committed, the group needs to have `signing_identity` as an entry
+    /// within an [ExternalSendersExt](crate::extension::built_in::ExternalSendersExt)
+    /// as part of its group context extensions.
+    #[cfg(all(feature = "by_ref_proposal", feature = "custom_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.propose(Proposal::Custom(proposal), authenticated_data)
+            .await
+    }
+
+    #[cfg(feature = "by_ref_proposal")]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn propose(
+        &mut self,
+        proposal: Proposal,
+        authenticated_data: Vec<u8>,
+    ) -> Result<MlsMessage, MlsError> {
+        let (signer, signing_identity) =
+            self.signing_data.as_ref().ok_or(MlsError::SignerNotFound)?;
+
+        let external_senders_ext = self
+            .state
+            .context
+            .extensions
+            .get_as::<ExternalSendersExt>()?
+            .ok_or(MlsError::ExternalProposalsDisabled)?;
+
+        let sender_index = external_senders_ext
+            .allowed_senders
+            .iter()
+            .position(|allowed_signer| signing_identity == allowed_signer)
+            .ok_or(MlsError::InvalidExternalSigningIdentity)?;
+
+        let sender = Sender::External(sender_index as u32);
+
+        let auth_content = AuthenticatedContent::new_signed(
+            &self.cipher_suite_provider,
+            &self.state.context,
+            sender,
+            Content::Proposal(Box::new(proposal.clone())),
+            signer,
+            WireFormat::PublicMessage,
+            authenticated_data,
+        )
+        .await?;
+
+        self.state.proposals.insert(
+            ProposalRef::from_content(&self.cipher_suite_provider, &auth_content).await?,
+            proposal,
+            sender,
+        );
+
+        let plaintext = PublicMessage {
+            content: auth_content.content,
+            auth: auth_content.auth,
+            membership_tag: None,
+        };
+
+        Ok(MlsMessage::new(
+            self.group_context().version(),
+            MlsMessagePayload::Plain(plaintext),
+        ))
+    }
+
+    /// 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()
+    }
+
+    #[inline(always)]
+    pub(crate) fn group_state(&self) -> &GroupState {
+        &self.state
+    }
+
+    /// Get the current group context summarizing various information about the group.
+    #[inline(always)]
+    pub fn group_context(&self) -> &GroupContext {
+        &self.group_state().context
+    }
+
+    /// Export the current ratchet tree used within the group.
+    pub fn export_tree(&self) -> Result<Vec<u8>, MlsError> {
+        self.group_state()
+            .public_tree
+            .nodes
+            .mls_encode_to_vec()
+            .map_err(Into::into)
+    }
+
+    /// Get the current roster of the group.
+    #[inline(always)]
+    pub fn roster(&self) -> Roster {
+        self.group_state().public_tree.roster()
+    }
+
+    /// Get the
+    /// [transcript hash](https://messaginglayersecurity.rocks/mls-protocol/draft-ietf-mls-protocol.html#name-transcript-hashes)
+    /// for the current epoch that the group is in.
+    #[inline(always)]
+    pub fn transcript_hash(&self) -> &Vec<u8> {
+        &self.group_state().context.confirmed_transcript_hash
+    }
+
+    /// Get the
+    /// [tree hash](https://www.rfc-editor.org/rfc/rfc9420.html#name-tree-hashes)
+    /// for the current epoch that the group is in.
+    #[inline(always)]
+    pub fn tree_hash(&self) -> &[u8] {
+        &self.group_state().context.tree_hash
+    }
+
+    /// Find a member based on their identity.
+    ///
+    /// Identities are matched based on the
+    /// [IdentityProvider](crate::IdentityProvider)
+    /// that this group was configured with.
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn get_member_with_identity(
+        &self,
+        identity_id: &SigningIdentity,
+    ) -> Result<Member, MlsError> {
+        let identity = self
+            .identity_provider()
+            .identity(identity_id, self.group_context().extensions())
+            .await
+            .map_err(|error| MlsError::IdentityProviderError(error.into_any_error()))?;
+
+        let tree = &self.group_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.group_context().extensions(),
+            )
+            .await?;
+
+        let index = index.ok_or(MlsError::MemberNotFound)?;
+        let node = self.group_state().public_tree.get_leaf_node(index)?;
+
+        Ok(member_from_leaf_node(node, index))
+    }
+}
+
+#[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 ExternalGroup<C>
+where
+    C: ExternalClientConfig + Clone,
+{
+    type MlsRules = C::MlsRules;
+    type IdentityProvider = C::IdentityProvider;
+    type PreSharedKeyStorage = AlwaysFoundPskStorage;
+    type OutputType = ExternalReceivedMessage;
+    type CipherSuiteProvider = <C::CryptoProvider as CryptoProvider>::CipherSuiteProvider;
+
+    #[cfg(feature = "private_message")]
+    fn self_index(&self) -> Option<LeafIndex> {
+        None
+    }
+
+    fn mls_rules(&self) -> Self::MlsRules {
+        self.config.mls_rules()
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn verify_plaintext_authentication(
+        &self,
+        message: PublicMessage,
+    ) -> Result<EventOrContent<Self::OutputType>, MlsError> {
+        let auth_content = crate::group::message_verifier::verify_plaintext_authentication(
+            &self.cipher_suite_provider,
+            message,
+            None,
+            None,
+            &self.state,
+        )
+        .await?;
+
+        Ok(EventOrContent::Content(auth_content))
+    }
+
+    #[cfg(feature = "private_message")]
+    async fn process_ciphertext(
+        &mut self,
+        cipher_text: &PrivateMessage,
+    ) -> Result<EventOrContent<Self::OutputType>, MlsError> {
+        Ok(EventOrContent::Event(ExternalReceivedMessage::Ciphertext(
+            cipher_text.content_type,
+        )))
+    }
+
+    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.state.context = provisional_public_state.group_context;
+        #[cfg(feature = "by_ref_proposal")]
+        self.state.proposals.clear();
+        self.state.interim_transcript_hash = interim_transcript_hash;
+        self.state.public_tree = provisional_public_state.public_tree;
+        self.state.confirmation_tag = confirmation_tag.clone();
+
+        Ok(())
+    }
+
+    fn identity_provider(&self) -> Self::IdentityProvider {
+        self.config.identity_provider()
+    }
+
+    fn psk_storage(&self) -> Self::PreSharedKeyStorage {
+        AlwaysFoundPskStorage
+    }
+
+    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 {
+        true
+    }
+
+    #[cfg(feature = "private_message")]
+    fn min_epoch_available(&self) -> Option<u64> {
+        self.config
+            .max_epoch_jitter()
+            .map(|j| self.state.context.epoch - j)
+    }
+
+    fn cipher_suite_provider(&self) -> &Self::CipherSuiteProvider {
+        &self.cipher_suite_provider
+    }
+}
+
+/// Serializable snapshot of an [ExternalGroup](ExternalGroup) state.
+#[derive(Debug, MlsEncode, MlsSize, MlsDecode, PartialEq, Clone)]
+pub struct ExternalSnapshot {
+    version: u16,
+    state: RawGroupState,
+    signing_data: Option<(SignatureSecretKey, SigningIdentity)>,
+}
+
+impl ExternalSnapshot {
+    /// Serialize the snapshot
+    pub fn to_bytes(&self) -> Result<Vec<u8>, MlsError> {
+        Ok(self.mls_encode_to_vec()?)
+    }
+
+    /// Deserialize the snapshot
+    pub fn from_bytes(bytes: &[u8]) -> Result<Self, MlsError> {
+        Ok(Self::mls_decode(&mut &*bytes)?)
+    }
+}
+
+impl<C> ExternalGroup<C>
+where
+    C: ExternalClientConfig + Clone,
+{
+    /// Create a snapshot of this group's current internal state.
+    pub fn snapshot(&self) -> ExternalSnapshot {
+        ExternalSnapshot {
+            state: RawGroupState::export(self.group_state()),
+            version: 1,
+            signing_data: self.signing_data.clone(),
+        }
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(crate) async fn from_snapshot(
+        config: C,
+        snapshot: ExternalSnapshot,
+    ) -> Result<Self, MlsError> {
+        #[cfg(feature = "tree_index")]
+        let identity_provider = config.identity_provider();
+
+        let cipher_suite_provider = cipher_suite_provider(
+            config.crypto_provider(),
+            snapshot.state.context.cipher_suite,
+        )?;
+
+        Ok(ExternalGroup {
+            config,
+            signing_data: snapshot.signing_data,
+            state: snapshot
+                .state
+                .import(
+                    #[cfg(feature = "tree_index")]
+                    &identity_provider,
+                )
+                .await?,
+            cipher_suite_provider,
+        })
+    }
+}
+
+impl From<CommitMessageDescription> for ExternalReceivedMessage {
+    fn from(value: CommitMessageDescription) -> Self {
+        ExternalReceivedMessage::Commit(value)
+    }
+}
+
+impl TryFrom<ApplicationMessageDescription> for ExternalReceivedMessage {
+    type Error = MlsError;
+
+    fn try_from(_: ApplicationMessageDescription) -> Result<Self, Self::Error> {
+        Err(MlsError::UnencryptedApplicationMessage)
+    }
+}
+
+impl From<ProposalMessageDescription> for ExternalReceivedMessage {
+    fn from(value: ProposalMessageDescription) -> Self {
+        ExternalReceivedMessage::Proposal(value)
+    }
+}
+
+impl From<GroupInfo> for ExternalReceivedMessage {
+    fn from(value: GroupInfo) -> Self {
+        ExternalReceivedMessage::GroupInfo(value)
+    }
+}
+
+impl From<Welcome> for ExternalReceivedMessage {
+    fn from(_: Welcome) -> Self {
+        ExternalReceivedMessage::Welcome
+    }
+}
+
+impl From<KeyPackage> for ExternalReceivedMessage {
+    fn from(value: KeyPackage) -> Self {
+        ExternalReceivedMessage::KeyPackage(value)
+    }
+}
+
+#[cfg(test)]
+pub(crate) mod test_utils {
+    use crate::{
+        external_client::tests_utils::{TestExternalClientBuilder, TestExternalClientConfig},
+        group::test_utils::TestGroup,
+    };
+
+    use super::ExternalGroup;
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(crate) async fn make_external_group(
+        group: &TestGroup,
+    ) -> ExternalGroup<TestExternalClientConfig> {
+        make_external_group_with_config(
+            group,
+            TestExternalClientBuilder::new_for_test().build_config(),
+        )
+        .await
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(crate) async fn make_external_group_with_config(
+        group: &TestGroup,
+        config: TestExternalClientConfig,
+    ) -> ExternalGroup<TestExternalClientConfig> {
+        ExternalGroup::join(
+            config,
+            None,
+            group
+                .group
+                .group_info_message_allowing_ext_commit(true)
+                .await
+                .unwrap(),
+            None,
+        )
+        .await
+        .unwrap()
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::test_utils::make_external_group;
+    use crate::{
+        cipher_suite::CipherSuite,
+        client::{
+            test_utils::{TEST_CIPHER_SUITE, TEST_PROTOCOL_VERSION},
+            MlsError,
+        },
+        crypto::{test_utils::TestCryptoProvider, SignatureSecretKey},
+        extension::ExternalSendersExt,
+        external_client::{
+            group::test_utils::make_external_group_with_config,
+            tests_utils::{TestExternalClientBuilder, TestExternalClientConfig},
+            ExternalGroup, ExternalReceivedMessage, ExternalSnapshot,
+        },
+        group::{
+            framing::{Content, MlsMessagePayload},
+            proposal::{AddProposal, Proposal, ProposalOrRef},
+            proposal_ref::ProposalRef,
+            test_utils::{test_group, TestGroup},
+            ProposalMessageDescription,
+        },
+        identity::{test_utils::get_test_signing_identity, SigningIdentity},
+        key_package::test_utils::{test_key_package, test_key_package_message},
+        protocol_version::ProtocolVersion,
+        ExtensionList, MlsMessage,
+    };
+    use assert_matches::assert_matches;
+    use mls_rs_codec::{MlsDecode, MlsEncode};
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn test_group_with_one_commit(v: ProtocolVersion, cs: CipherSuite) -> TestGroup {
+        let mut group = test_group(v, cs).await;
+        group.group.commit(Vec::new()).await.unwrap();
+        group.process_pending_commit().await.unwrap();
+        group
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn test_group_two_members(
+        v: ProtocolVersion,
+        cs: CipherSuite,
+        #[cfg(feature = "by_ref_proposal")] ext_identity: Option<SigningIdentity>,
+    ) -> TestGroup {
+        let mut group = test_group_with_one_commit(v, cs).await;
+
+        let bob_key_package = test_key_package_message(v, cs, "bob").await;
+
+        let mut commit_builder = group
+            .group
+            .commit_builder()
+            .add_member(bob_key_package)
+            .unwrap();
+
+        #[cfg(feature = "by_ref_proposal")]
+        if let Some(ext_signer) = ext_identity {
+            let mut ext_list = ExtensionList::new();
+
+            ext_list
+                .set_from(ExternalSendersExt {
+                    allowed_senders: vec![ext_signer],
+                })
+                .unwrap();
+
+            commit_builder = commit_builder.set_group_context_ext(ext_list).unwrap();
+        }
+
+        commit_builder.build().await.unwrap();
+
+        group.process_pending_commit().await.unwrap();
+        group
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn external_group_can_be_created() {
+        for (v, cs) in ProtocolVersion::all().flat_map(|v| {
+            TestCryptoProvider::all_supported_cipher_suites()
+                .into_iter()
+                .map(move |cs| (v, cs))
+        }) {
+            make_external_group(&test_group_with_one_commit(v, cs).await).await;
+        }
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn external_group_can_process_commit() {
+        let mut alice = test_group_with_one_commit(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await;
+        let mut server = make_external_group(&alice).await;
+        let commit_output = alice.group.commit(Vec::new()).await.unwrap();
+        alice.group.apply_pending_commit().await.unwrap();
+
+        server
+            .process_incoming_message(commit_output.commit_message)
+            .await
+            .unwrap();
+
+        assert_eq!(alice.group.state, server.state);
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn external_group_can_process_proposals_by_reference() {
+        let mut alice = test_group_with_one_commit(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await;
+        let mut server = make_external_group(&alice).await;
+
+        let bob_key_package =
+            test_key_package(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, "bob").await;
+
+        let add_proposal = Proposal::Add(Box::new(AddProposal {
+            key_package: bob_key_package,
+        }));
+
+        let packet = alice.propose(add_proposal.clone()).await;
+
+        let proposal_process = server.process_incoming_message(packet).await.unwrap();
+
+        assert_matches!(
+            proposal_process,
+            ExternalReceivedMessage::Proposal(ProposalMessageDescription { ref proposal, ..}) if proposal == &add_proposal
+        );
+
+        let commit_output = alice.group.commit(vec![]).await.unwrap();
+        alice.group.apply_pending_commit().await.unwrap();
+
+        let commit_result = server
+            .process_incoming_message(commit_output.commit_message)
+            .await
+            .unwrap();
+
+        #[cfg(feature = "state_update")]
+        assert_matches!(
+            commit_result,
+            ExternalReceivedMessage::Commit(commit_description)
+                if commit_description.state_update.roster_update.added().iter().any(|added| added.index == 1)
+        );
+
+        #[cfg(not(feature = "state_update"))]
+        assert_matches!(commit_result, ExternalReceivedMessage::Commit(_));
+
+        assert_eq!(alice.group.state, server.state);
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn external_group_can_process_commit_adding_member() {
+        let mut alice = test_group_with_one_commit(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await;
+        let mut server = make_external_group(&alice).await;
+        let (_, commit) = alice.join("bob").await;
+
+        let update = match server.process_incoming_message(commit).await.unwrap() {
+            ExternalReceivedMessage::Commit(update) => update.state_update,
+            _ => panic!("Expected processed commit"),
+        };
+
+        #[cfg(feature = "state_update")]
+        assert_eq!(update.roster_update.added().len(), 1);
+
+        assert_eq!(server.state.public_tree.get_leaf_nodes().len(), 2);
+
+        assert_eq!(alice.group.state, server.state);
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn external_group_rejects_commit_not_for_current_epoch() {
+        let mut alice = test_group_with_one_commit(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await;
+        let mut server = make_external_group(&alice).await;
+
+        let mut commit_output = alice.group.commit(vec![]).await.unwrap();
+
+        match commit_output.commit_message.payload {
+            MlsMessagePayload::Plain(ref mut plain) => plain.content.epoch = 0,
+            _ => panic!("Unexpected non-plaintext data"),
+        };
+
+        let res = server
+            .process_incoming_message(commit_output.commit_message)
+            .await;
+
+        assert_matches!(res, Err(MlsError::InvalidEpoch));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn external_group_can_reject_message_with_invalid_signature() {
+        let mut alice = test_group_with_one_commit(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await;
+
+        let mut server = make_external_group_with_config(
+            &alice,
+            TestExternalClientBuilder::new_for_test().build_config(),
+        )
+        .await;
+
+        let mut commit_output = alice.group.commit(Vec::new()).await.unwrap();
+
+        match commit_output.commit_message.payload {
+            MlsMessagePayload::Plain(ref mut plain) => plain.auth.signature = Vec::new().into(),
+            _ => panic!("Unexpected non-plaintext data"),
+        };
+
+        let res = server
+            .process_incoming_message(commit_output.commit_message)
+            .await;
+
+        assert_matches!(res, Err(MlsError::InvalidSignature));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn external_group_rejects_unencrypted_application_message() {
+        let mut alice = test_group_with_one_commit(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await;
+        let mut server = make_external_group(&alice).await;
+
+        let plaintext = alice
+            .make_plaintext(Content::Application(b"hello".to_vec().into()))
+            .await;
+
+        let res = server.process_incoming_message(plaintext).await;
+
+        assert_matches!(res, Err(MlsError::UnencryptedApplicationMessage));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn external_group_will_reject_unsupported_cipher_suites() {
+        let alice = test_group_with_one_commit(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await;
+
+        let config =
+            TestExternalClientBuilder::new_for_test_disabling_cipher_suite(TEST_CIPHER_SUITE)
+                .build_config();
+
+        let res = ExternalGroup::join(
+            config,
+            None,
+            alice
+                .group
+                .group_info_message_allowing_ext_commit(true)
+                .await
+                .unwrap(),
+            None,
+        )
+        .await
+        .map(|_| ());
+
+        assert_matches!(
+            res,
+            Err(MlsError::UnsupportedCipherSuite(TEST_CIPHER_SUITE))
+        );
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn external_group_will_reject_unsupported_protocol_versions() {
+        let alice = test_group_with_one_commit(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await;
+
+        let config = TestExternalClientBuilder::new_for_test().build_config();
+
+        let mut group_info = alice
+            .group
+            .group_info_message_allowing_ext_commit(true)
+            .await
+            .unwrap();
+
+        group_info.version = ProtocolVersion::from(64);
+
+        let res = ExternalGroup::join(config, None, group_info, None)
+            .await
+            .map(|_| ());
+
+        assert_matches!(
+            res,
+            Err(MlsError::UnsupportedProtocolVersion(v)) if v ==
+                ProtocolVersion::from(64)
+        );
+    }
+
+    #[cfg(feature = "by_ref_proposal")]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn setup_extern_proposal_test(
+        extern_proposals_allowed: bool,
+    ) -> (SigningIdentity, SignatureSecretKey, TestGroup) {
+        let (server_identity, server_key) =
+            get_test_signing_identity(TEST_CIPHER_SUITE, b"server").await;
+
+        let alice = test_group_two_members(
+            TEST_PROTOCOL_VERSION,
+            TEST_CIPHER_SUITE,
+            extern_proposals_allowed.then(|| server_identity.clone()),
+        )
+        .await;
+
+        (server_identity, server_key, alice)
+    }
+
+    #[cfg(feature = "by_ref_proposal")]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn test_external_proposal(
+        server: &mut ExternalGroup<TestExternalClientConfig>,
+        alice: &mut TestGroup,
+        external_proposal: MlsMessage,
+    ) {
+        let auth_content = external_proposal.clone().into_plaintext().unwrap().into();
+
+        let proposal_ref = ProposalRef::from_content(&server.cipher_suite_provider, &auth_content)
+            .await
+            .unwrap();
+
+        // Alice receives the proposal
+        alice.process_message(external_proposal).await.unwrap();
+
+        // Alice commits the proposal
+        let commit_output = alice.group.commit(vec![]).await.unwrap();
+
+        let commit = match commit_output
+            .commit_message
+            .clone()
+            .into_plaintext()
+            .unwrap()
+            .content
+            .content
+        {
+            Content::Commit(commit) => commit,
+            _ => panic!("not a commit"),
+        };
+
+        // The proposal should be in the resulting commit
+        assert!(commit
+            .proposals
+            .contains(&ProposalOrRef::Reference(proposal_ref)));
+
+        alice.process_pending_commit().await.unwrap();
+
+        server
+            .process_incoming_message(commit_output.commit_message)
+            .await
+            .unwrap();
+
+        assert_eq!(alice.group.state, server.state);
+    }
+
+    #[cfg(feature = "by_ref_proposal")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn external_group_can_propose_add() {
+        let (server_identity, server_key, mut alice) = setup_extern_proposal_test(true).await;
+
+        let mut server = make_external_group(&alice).await;
+
+        server.signing_data = Some((server_key, server_identity));
+
+        let charlie_key_package =
+            test_key_package_message(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, "charlie").await;
+
+        let external_proposal = server
+            .propose_add(charlie_key_package, vec![])
+            .await
+            .unwrap();
+
+        test_external_proposal(&mut server, &mut alice, external_proposal).await
+    }
+
+    #[cfg(feature = "by_ref_proposal")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn external_group_can_propose_remove() {
+        let (server_identity, server_key, mut alice) = setup_extern_proposal_test(true).await;
+
+        let mut server = make_external_group(&alice).await;
+
+        server.signing_data = Some((server_key, server_identity));
+
+        let external_proposal = server.propose_remove(1, vec![]).await.unwrap();
+
+        test_external_proposal(&mut server, &mut alice, external_proposal).await
+    }
+
+    #[cfg(feature = "by_ref_proposal")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn external_group_external_proposal_not_allowed() {
+        let (signing_id, secret_key, alice) = setup_extern_proposal_test(false).await;
+        let mut server = make_external_group(&alice).await;
+
+        server.signing_data = Some((secret_key, signing_id));
+
+        let charlie_key_package =
+            test_key_package_message(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, "charlie").await;
+
+        let res = server.propose_add(charlie_key_package, vec![]).await;
+
+        assert_matches!(res, Err(MlsError::ExternalProposalsDisabled));
+    }
+
+    #[cfg(feature = "by_ref_proposal")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn external_group_external_signing_identity_invalid() {
+        let (server_identity, server_key) =
+            get_test_signing_identity(TEST_CIPHER_SUITE, b"server").await;
+
+        let alice = test_group_two_members(
+            TEST_PROTOCOL_VERSION,
+            TEST_CIPHER_SUITE,
+            Some(
+                get_test_signing_identity(TEST_CIPHER_SUITE, b"not server")
+                    .await
+                    .0,
+            ),
+        )
+        .await;
+
+        let mut server = make_external_group(&alice).await;
+
+        server.signing_data = Some((server_key, server_identity));
+
+        let res = server.propose_remove(1, vec![]).await;
+
+        assert_matches!(res, Err(MlsError::InvalidExternalSigningIdentity));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn external_group_errors_on_old_epoch() {
+        let mut alice = test_group_with_one_commit(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await;
+
+        let mut server = make_external_group_with_config(
+            &alice,
+            TestExternalClientBuilder::new_for_test()
+                .max_epoch_jitter(0)
+                .build_config(),
+        )
+        .await;
+
+        let old_application_msg = alice
+            .group
+            .encrypt_application_message(&[], vec![])
+            .await
+            .unwrap();
+
+        let commit_output = alice.group.commit(vec![]).await.unwrap();
+
+        server
+            .process_incoming_message(commit_output.commit_message)
+            .await
+            .unwrap();
+
+        let res = server.process_incoming_message(old_application_msg).await;
+
+        assert_matches!(res, Err(MlsError::InvalidEpoch));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn proposals_can_be_cached_externally() {
+        let mut alice = test_group_with_one_commit(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await;
+
+        let mut server = make_external_group_with_config(
+            &alice,
+            TestExternalClientBuilder::new_for_test()
+                .cache_proposals(false)
+                .build_config(),
+        )
+        .await;
+
+        let proposal = alice.group.propose_update(vec![]).await.unwrap();
+
+        let commit_output = alice.group.commit(vec![]).await.unwrap();
+
+        server
+            .process_incoming_message(proposal.clone())
+            .await
+            .unwrap();
+
+        server.insert_proposal_from_message(proposal).await.unwrap();
+
+        server
+            .process_incoming_message(commit_output.commit_message)
+            .await
+            .unwrap();
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn external_group_can_observe_since_creation() {
+        let mut alice = test_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await;
+
+        let info = alice
+            .group
+            .group_info_message_allowing_ext_commit(true)
+            .await
+            .unwrap();
+
+        let config = TestExternalClientBuilder::new_for_test().build_config();
+        let mut server = ExternalGroup::join(config, None, info, None).await.unwrap();
+
+        for _ in 0..2 {
+            let commit = alice.group.commit(vec![]).await.unwrap().commit_message;
+            alice.process_pending_commit().await.unwrap();
+            server.process_incoming_message(commit).await.unwrap();
+        }
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn external_group_can_be_serialized_to_tls_encoding() {
+        let server =
+            make_external_group(&test_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await).await;
+
+        let snapshot = server.snapshot().mls_encode_to_vec().unwrap();
+        let snapshot_restored = ExternalSnapshot::mls_decode(&mut snapshot.as_slice()).unwrap();
+
+        let server_restored =
+            ExternalGroup::from_snapshot(server.config.clone(), snapshot_restored)
+                .await
+                .unwrap();
+
+        assert_eq!(server.group_state(), server_restored.group_state());
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn external_group_can_validate_info() {
+        let alice = test_group_with_one_commit(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await;
+        let mut server = make_external_group(&alice).await;
+
+        let info = alice
+            .group
+            .group_info_message_allowing_ext_commit(false)
+            .await
+            .unwrap();
+
+        let update = server.process_incoming_message(info.clone()).await.unwrap();
+        let info = info.into_group_info().unwrap();
+
+        assert_matches!(update, ExternalReceivedMessage::GroupInfo(update_info) if update_info == info);
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn external_group_can_validate_key_package() {
+        let alice = test_group_with_one_commit(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await;
+        let mut server = make_external_group(&alice).await;
+
+        let kp = test_key_package_message(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, "john").await;
+
+        let update = server.process_incoming_message(kp.clone()).await.unwrap();
+        let kp = kp.into_key_package().unwrap();
+
+        assert_matches!(update, ExternalReceivedMessage::KeyPackage(update_kp) if update_kp == kp);
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn external_group_can_validate_welcome() {
+        let mut alice = test_group_with_one_commit(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await;
+        let mut server = make_external_group(&alice).await;
+
+        let [welcome] = alice
+            .group
+            .commit_builder()
+            .add_member(
+                test_key_package_message(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, "john").await,
+            )
+            .unwrap()
+            .build()
+            .await
+            .unwrap()
+            .welcome_messages
+            .try_into()
+            .unwrap();
+
+        let update = server.process_incoming_message(welcome).await.unwrap();
+
+        assert_matches!(update, ExternalReceivedMessage::Welcome);
+    }
+}
diff --git a/src/grease.rs b/src/grease.rs
new file mode 100644
index 0000000..cd4f208
--- /dev/null
+++ b/src/grease.rs
@@ -0,0 +1,227 @@
+// 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::CipherSuiteProvider, extension::ExtensionList, group::Capabilities};
+
+use crate::{
+    client::MlsError,
+    group::{GroupInfo, NewMemberInfo},
+    key_package::KeyPackage,
+    tree_kem::leaf_node::LeafNode,
+};
+
+impl LeafNode {
+    pub fn ungreased_capabilities(&self) -> Capabilities {
+        let mut capabilitites = self.capabilities.clone();
+        grease_functions::ungrease(&mut capabilitites.cipher_suites);
+        grease_functions::ungrease(&mut capabilitites.extensions);
+        grease_functions::ungrease(&mut capabilitites.proposals);
+        grease_functions::ungrease(&mut capabilitites.credentials);
+        capabilitites
+    }
+
+    pub fn ungreased_extensions(&self) -> ExtensionList {
+        let mut extensions = self.extensions.clone();
+        grease_functions::ungrease_extensions(&mut extensions);
+        extensions
+    }
+
+    pub fn grease<P: CipherSuiteProvider>(&mut self, cs: &P) -> Result<(), MlsError> {
+        grease_functions::grease(&mut self.capabilities.cipher_suites, cs)?;
+        grease_functions::grease(&mut self.capabilities.proposals, cs)?;
+        grease_functions::grease(&mut self.capabilities.credentials, cs)?;
+
+        let mut new_extensions = grease_functions::grease_extensions(&mut self.extensions, cs)?;
+        self.capabilities.extensions.append(&mut new_extensions);
+
+        Ok(())
+    }
+}
+
+impl KeyPackage {
+    pub fn grease<P: CipherSuiteProvider>(&mut self, cs: &P) -> Result<(), MlsError> {
+        grease_functions::grease_extensions(&mut self.extensions, cs).map(|_| ())
+    }
+
+    pub fn ungreased_extensions(&self) -> ExtensionList {
+        let mut extensions = self.extensions.clone();
+        grease_functions::ungrease_extensions(&mut extensions);
+        extensions
+    }
+}
+
+impl GroupInfo {
+    pub fn grease<P: CipherSuiteProvider>(&mut self, cs: &P) -> Result<(), MlsError> {
+        grease_functions::grease_extensions(&mut self.extensions, cs).map(|_| ())
+    }
+}
+
+impl NewMemberInfo {
+    pub fn ungrease(&mut self) {
+        grease_functions::ungrease_extensions(&mut self.group_info_extensions)
+    }
+}
+
+#[cfg(feature = "grease")]
+mod grease_functions {
+    use core::ops::Deref;
+
+    use mls_rs_core::{
+        crypto::CipherSuiteProvider,
+        error::IntoAnyError,
+        extension::{Extension, ExtensionList, ExtensionType},
+    };
+
+    use super::MlsError;
+
+    pub const GREASE_VALUES: &[u16] = &[
+        0x0A0A, 0x1A1A, 0x2A2A, 0x3A3A, 0x4A4A, 0x5A5A, 0x6A6A, 0x7A7A, 0x8A8A, 0x9A9A, 0xAAAA,
+        0xBABA, 0xCACA, 0xDADA, 0xEAEA,
+    ];
+
+    pub fn grease<T: From<u16>, P: CipherSuiteProvider>(
+        array: &mut Vec<T>,
+        cs: &P,
+    ) -> Result<(), MlsError> {
+        array.push(random_grease_value(cs)?.into());
+        Ok(())
+    }
+
+    pub fn grease_extensions<P: CipherSuiteProvider>(
+        extensions: &mut ExtensionList,
+        cs: &P,
+    ) -> Result<Vec<ExtensionType>, MlsError> {
+        let grease_value = random_grease_value(cs)?;
+        extensions.set(Extension::new(grease_value.into(), vec![]));
+        Ok(vec![grease_value.into()])
+    }
+
+    fn random_grease_value<P: CipherSuiteProvider>(cs: &P) -> Result<u16, MlsError> {
+        let index = cs
+            .random_bytes_vec(1)
+            .map_err(|e| MlsError::CryptoProviderError(e.into_any_error()))?[0];
+
+        Ok(GREASE_VALUES[index as usize % GREASE_VALUES.len()])
+    }
+
+    pub fn ungrease<T: Deref<Target = u16>>(array: &mut Vec<T>) {
+        array.retain(|x| !GREASE_VALUES.contains(&**x));
+    }
+
+    pub fn ungrease_extensions(extensions: &mut ExtensionList) {
+        for e in GREASE_VALUES {
+            extensions.remove((*e).into())
+        }
+    }
+}
+
+#[cfg(not(feature = "grease"))]
+mod grease_functions {
+    use core::ops::Deref;
+
+    use alloc::vec::Vec;
+
+    use mls_rs_core::{
+        crypto::CipherSuiteProvider,
+        extension::{ExtensionList, ExtensionType},
+    };
+
+    use super::MlsError;
+
+    pub fn grease<T: From<u16>, P: CipherSuiteProvider>(
+        _array: &mut [T],
+        _cs: &P,
+    ) -> Result<(), MlsError> {
+        Ok(())
+    }
+
+    pub fn grease_extensions<P: CipherSuiteProvider>(
+        _extensions: &mut ExtensionList,
+        _cs: &P,
+    ) -> Result<Vec<ExtensionType>, MlsError> {
+        Ok(Vec::new())
+    }
+
+    pub fn ungrease<T: Deref<Target = u16>>(_array: &mut [T]) {}
+
+    pub fn ungrease_extensions(_extensions: &mut ExtensionList) {}
+}
+
+#[cfg(all(test, feature = "grease"))]
+mod tests {
+    #[cfg(target_arch = "wasm32")]
+    use wasm_bindgen_test::wasm_bindgen_test as test;
+
+    use std::ops::Deref;
+
+    use mls_rs_core::extension::ExtensionList;
+
+    use crate::{
+        client::test_utils::{test_client_with_key_pkg, TEST_CIPHER_SUITE, TEST_PROTOCOL_VERSION},
+        group::test_utils::test_group,
+    };
+
+    use super::grease_functions::GREASE_VALUES;
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn key_package_is_greased() {
+        let key_pkg = test_client_with_key_pkg(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, "alice")
+            .await
+            .1
+            .into_key_package()
+            .unwrap();
+
+        assert!(is_ext_greased(&key_pkg.extensions));
+        assert!(is_ext_greased(&key_pkg.leaf_node.extensions));
+        assert!(is_greased(&key_pkg.leaf_node.capabilities.cipher_suites));
+        assert!(is_greased(&key_pkg.leaf_node.capabilities.extensions));
+        assert!(is_greased(&key_pkg.leaf_node.capabilities.proposals));
+        assert!(is_greased(&key_pkg.leaf_node.capabilities.credentials));
+
+        assert!(!is_greased(
+            &key_pkg.leaf_node.capabilities.protocol_versions
+        ));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn group_info_is_greased() {
+        let group_info = test_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE)
+            .await
+            .group
+            .group_info_message_allowing_ext_commit(false)
+            .await
+            .unwrap()
+            .into_group_info()
+            .unwrap();
+
+        assert!(is_ext_greased(&group_info.extensions));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn public_api_is_not_greased() {
+        let member = test_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE)
+            .await
+            .group
+            .roster()
+            .member_with_index(0)
+            .unwrap();
+
+        assert!(!is_ext_greased(member.extensions()));
+        assert!(!is_greased(member.capabilities().protocol_versions()));
+        assert!(!is_greased(member.capabilities().cipher_suites()));
+        assert!(!is_greased(member.capabilities().extensions()));
+        assert!(!is_greased(member.capabilities().proposals()));
+        assert!(!is_greased(member.capabilities().credentials()));
+    }
+
+    fn is_greased<T: Deref<Target = u16>>(list: &[T]) -> bool {
+        list.iter().any(|v| GREASE_VALUES.contains(v))
+    }
+
+    fn is_ext_greased(extensions: &ExtensionList) -> bool {
+        extensions
+            .iter()
+            .any(|ext| GREASE_VALUES.contains(&*ext.extension_type()))
+    }
+}
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))
+}
diff --git a/src/hash_reference.rs b/src/hash_reference.rs
new file mode 100644
index 0000000..41cb156
--- /dev/null
+++ b/src/hash_reference.rs
@@ -0,0 +1,166 @@
+// 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::{
+    fmt::{self, Debug},
+    ops::Deref,
+};
+
+use crate::client::MlsError;
+use crate::CipherSuiteProvider;
+use alloc::vec::Vec;
+use mls_rs_codec::{MlsDecode, MlsEncode, MlsSize};
+use mls_rs_core::error::IntoAnyError;
+
+#[derive(MlsSize, MlsEncode)]
+struct RefHashInput<'a> {
+    #[mls_codec(with = "mls_rs_codec::byte_vec")]
+    pub label: &'a [u8],
+    #[mls_codec(with = "mls_rs_codec::byte_vec")]
+    pub value: &'a [u8],
+}
+
+impl Debug for RefHashInput<'_> {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.debug_struct("RefHashInput")
+            .field("label", &mls_rs_core::debug::pretty_bytes(self.label))
+            .field("value", &mls_rs_core::debug::pretty_bytes(self.value))
+            .finish()
+    }
+}
+
+#[derive(PartialEq, Eq, PartialOrd, Ord, Hash, Clone, MlsSize, MlsEncode, MlsDecode)]
+#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+pub struct HashReference(
+    #[mls_codec(with = "mls_rs_codec::byte_vec")]
+    #[cfg_attr(feature = "serde", serde(with = "mls_rs_core::vec_serde"))]
+    Vec<u8>,
+);
+
+impl Debug for HashReference {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        mls_rs_core::debug::pretty_bytes(&self.0)
+            .named("HashReference")
+            .fmt(f)
+    }
+}
+
+impl Deref for HashReference {
+    type Target = [u8];
+
+    fn deref(&self) -> &Self::Target {
+        &self.0
+    }
+}
+
+impl AsRef<[u8]> for HashReference {
+    fn as_ref(&self) -> &[u8] {
+        &self.0
+    }
+}
+
+impl From<Vec<u8>> for HashReference {
+    fn from(val: Vec<u8>) -> Self {
+        Self(val)
+    }
+}
+
+impl HashReference {
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn compute<P: CipherSuiteProvider>(
+        value: &[u8],
+        label: &[u8],
+        cipher_suite: &P,
+    ) -> Result<HashReference, MlsError> {
+        let input = RefHashInput { label, value };
+        let input_bytes = input.mls_encode_to_vec()?;
+
+        cipher_suite
+            .hash(&input_bytes)
+            .await
+            .map_err(|e| MlsError::CryptoProviderError(e.into_any_error()))
+            .map(HashReference)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use crate::crypto::test_utils::try_test_cipher_suite_provider;
+
+    #[cfg(not(mls_build_async))]
+    use crate::{cipher_suite::CipherSuite, crypto::test_utils::test_cipher_suite_provider};
+
+    use super::*;
+    use alloc::string::String;
+    use serde::{Deserialize, Serialize};
+
+    #[cfg(not(mls_build_async))]
+    use alloc::string::ToString;
+
+    #[cfg(target_arch = "wasm32")]
+    use wasm_bindgen_test::wasm_bindgen_test as test;
+
+    #[derive(Debug, Deserialize, Serialize)]
+    struct HashRefTestCase {
+        label: String,
+        #[serde(with = "hex::serde")]
+        value: Vec<u8>,
+        #[serde(with = "hex::serde")]
+        out: Vec<u8>,
+    }
+
+    #[derive(Debug, serde::Serialize, serde::Deserialize)]
+    pub struct InteropTestCase {
+        cipher_suite: u16,
+        ref_hash: HashRefTestCase,
+    }
+
+    #[cfg(not(mls_build_async))]
+    #[cfg_attr(coverage_nightly, coverage(off))]
+    fn generate_test_vector() -> Vec<InteropTestCase> {
+        CipherSuite::all()
+            .map(|cipher_suite| {
+                let provider = test_cipher_suite_provider(cipher_suite);
+
+                let input = b"test input";
+                let label = "test label";
+
+                let output = HashReference::compute(input, label.as_bytes(), &provider).unwrap();
+
+                let ref_hash = HashRefTestCase {
+                    label: label.to_string(),
+                    value: input.to_vec(),
+                    out: output.to_vec(),
+                };
+
+                InteropTestCase {
+                    cipher_suite: cipher_suite.into(),
+                    ref_hash,
+                }
+            })
+            .collect()
+    }
+
+    #[cfg(mls_build_async)]
+    fn generate_test_vector() -> Vec<InteropTestCase> {
+        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_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, generate_test_vector());
+
+        for test_case in test_cases {
+            if let Some(cs) = try_test_cipher_suite_provider(test_case.cipher_suite) {
+                let label = test_case.ref_hash.label.as_bytes();
+                let value = &test_case.ref_hash.value;
+                let computed = HashReference::compute(value, label, &cs).await.unwrap();
+                assert_eq!(&*computed, &test_case.ref_hash.out);
+            }
+        }
+    }
+}
diff --git a/src/identity.rs b/src/identity.rs
new file mode 100644
index 0000000..5de7a11
--- /dev/null
+++ b/src/identity.rs
@@ -0,0 +1,182 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// Copyright by contributors to this project.
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+
+/// Basic credential identity provider.
+pub mod basic;
+
+/// X.509 certificate identity provider.
+#[cfg(feature = "x509")]
+pub mod x509 {
+    pub use mls_rs_identity_x509::*;
+}
+
+pub use mls_rs_core::identity::{
+    Credential, CredentialType, CustomCredential, MlsCredential, SigningIdentity,
+};
+
+pub use mls_rs_core::group::RosterUpdate;
+
+#[cfg(test)]
+pub(crate) mod test_utils {
+    use alloc::boxed::Box;
+    use alloc::vec;
+    use alloc::vec::Vec;
+    use mls_rs_core::{
+        crypto::{CipherSuite, CipherSuiteProvider, SignatureSecretKey},
+        error::IntoAnyError,
+        extension::ExtensionList,
+        identity::{Credential, CredentialType, IdentityProvider, SigningIdentity},
+        time::MlsTime,
+    };
+
+    use crate::crypto::test_utils::test_cipher_suite_provider;
+
+    use super::basic::{BasicCredential, BasicIdentityProvider};
+
+    #[derive(Debug)]
+    #[cfg_attr(feature = "std", derive(thiserror::Error))]
+    #[cfg_attr(
+        feature = "std",
+        error("expected basic or custom credential type 42 found: {0:?}")
+    )]
+    pub struct BasicWithCustomProviderError(CredentialType);
+
+    impl IntoAnyError for BasicWithCustomProviderError {
+        #[cfg(feature = "std")]
+        fn into_dyn_error(self) -> Result<Box<dyn std::error::Error + Send + Sync>, Self> {
+            Ok(self.into())
+        }
+    }
+
+    #[derive(Debug, Clone)]
+    pub struct BasicWithCustomProvider {
+        pub(crate) basic: BasicIdentityProvider,
+        pub(crate) allow_any_custom: bool,
+        supported_cred_types: Vec<CredentialType>,
+    }
+
+    impl BasicWithCustomProvider {
+        pub const CUSTOM_CREDENTIAL_TYPE: u16 = 42;
+
+        pub fn new(basic: BasicIdentityProvider) -> BasicWithCustomProvider {
+            BasicWithCustomProvider {
+                basic,
+                allow_any_custom: false,
+                supported_cred_types: vec![
+                    CredentialType::BASIC,
+                    Self::CUSTOM_CREDENTIAL_TYPE.into(),
+                ],
+            }
+        }
+
+        pub fn with_credential_type(mut self, cred_type: CredentialType) -> Self {
+            self.supported_cred_types.push(cred_type);
+            self
+        }
+
+        #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+        async fn resolve_custom_identity(
+            &self,
+            signing_id: &SigningIdentity,
+        ) -> Result<Vec<u8>, BasicWithCustomProviderError> {
+            self.basic
+                .identity(signing_id, &Default::default())
+                .await
+                .or_else(|_| {
+                    signing_id
+                        .credential
+                        .as_custom()
+                        .map(|c| {
+                            if c.credential_type
+                                == CredentialType::from(Self::CUSTOM_CREDENTIAL_TYPE)
+                                || self.allow_any_custom
+                            {
+                                Ok(c.data.to_vec())
+                            } else {
+                                Err(BasicWithCustomProviderError(c.credential_type))
+                            }
+                        })
+                        .transpose()?
+                        .ok_or_else(|| {
+                            BasicWithCustomProviderError(signing_id.credential.credential_type())
+                        })
+                })
+        }
+    }
+
+    impl Default for BasicWithCustomProvider {
+        fn default() -> Self {
+            Self::new(BasicIdentityProvider::new())
+        }
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    #[cfg_attr(mls_build_async, maybe_async::must_be_async)]
+    impl IdentityProvider for BasicWithCustomProvider {
+        type Error = BasicWithCustomProviderError;
+
+        async fn validate_member(
+            &self,
+            _signing_identity: &SigningIdentity,
+            _timestamp: Option<MlsTime>,
+            _extensions: Option<&ExtensionList>,
+        ) -> Result<(), Self::Error> {
+            //TODO: Is it actually beneficial to check the key, or does that already happen elsewhere before
+            //this point?
+            Ok(())
+        }
+
+        async fn validate_external_sender(
+            &self,
+            _signing_identity: &SigningIdentity,
+            _timestamp: Option<MlsTime>,
+            _extensions: Option<&ExtensionList>,
+        ) -> Result<(), Self::Error> {
+            //TODO: Is it actually beneficial to check the key, or does that already happen elsewhere before
+            //this point?
+            Ok(())
+        }
+
+        async fn identity(
+            &self,
+            signing_id: &SigningIdentity,
+            _extensions: &ExtensionList,
+        ) -> Result<Vec<u8>, Self::Error> {
+            self.resolve_custom_identity(signing_id).await
+        }
+
+        async fn valid_successor(
+            &self,
+            predecessor: &SigningIdentity,
+            successor: &SigningIdentity,
+            _extensions: &ExtensionList,
+        ) -> Result<bool, Self::Error> {
+            let predecessor = self.resolve_custom_identity(predecessor).await?;
+            let successor = self.resolve_custom_identity(successor).await?;
+
+            Ok(predecessor == successor)
+        }
+
+        fn supported_types(&self) -> Vec<CredentialType> {
+            self.supported_cred_types.clone()
+        }
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn get_test_signing_identity(
+        cipher_suite: CipherSuite,
+        identity: &[u8],
+    ) -> (SigningIdentity, SignatureSecretKey) {
+        let provider = test_cipher_suite_provider(cipher_suite);
+        let (secret_key, public_key) = provider.signature_key_generate().await.unwrap();
+
+        let basic = get_test_basic_credential(identity.to_vec());
+
+        (SigningIdentity::new(basic, public_key), secret_key)
+    }
+
+    pub fn get_test_basic_credential(identity: Vec<u8>) -> Credential {
+        BasicCredential::new(identity).into_credential()
+    }
+}
diff --git a/src/identity/basic.rs b/src/identity/basic.rs
new file mode 100644
index 0000000..b93ab6a
--- /dev/null
+++ b/src/identity/basic.rs
@@ -0,0 +1,99 @@
+// 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::{identity::CredentialType, identity::SigningIdentity, time::MlsTime};
+use alloc::vec;
+use alloc::vec::Vec;
+pub use mls_rs_core::identity::BasicCredential;
+use mls_rs_core::{error::IntoAnyError, extension::ExtensionList, identity::IdentityProvider};
+
+#[derive(Debug)]
+#[cfg_attr(feature = "std", derive(thiserror::Error))]
+#[cfg_attr(feature = "std", error("unsupported credential type found: {0:?}"))]
+/// Error returned in the event that a non-basic
+/// credential is passed to a [`BasicIdentityProvider`].
+pub struct BasicIdentityProviderError(CredentialType);
+
+impl IntoAnyError for BasicIdentityProviderError {
+    #[cfg(feature = "std")]
+    fn into_dyn_error(self) -> Result<Box<dyn std::error::Error + Send + Sync>, Self> {
+        Ok(self.into())
+    }
+}
+
+impl BasicIdentityProviderError {
+    pub fn credential_type(&self) -> CredentialType {
+        self.0
+    }
+}
+
+#[derive(Clone, Debug, Default)]
+/// An always-valid identity provider that works with [`BasicCredential`].
+///
+/// # Warning
+///
+/// This provider always returns `true` for `validate` as long as the
+/// [`SigningIdentity`] used contains a [`BasicCredential`]. It is only
+/// recommended to use this provider for testing purposes.
+pub struct BasicIdentityProvider;
+
+impl BasicIdentityProvider {
+    pub fn new() -> Self {
+        Self
+    }
+}
+
+fn resolve_basic_identity(
+    signing_id: &SigningIdentity,
+) -> Result<&BasicCredential, BasicIdentityProviderError> {
+    signing_id
+        .credential
+        .as_basic()
+        .ok_or_else(|| BasicIdentityProviderError(signing_id.credential.credential_type()))
+}
+
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+#[cfg_attr(mls_build_async, maybe_async::must_be_async)]
+impl IdentityProvider for BasicIdentityProvider {
+    type Error = BasicIdentityProviderError;
+
+    async fn validate_member(
+        &self,
+        signing_identity: &SigningIdentity,
+        _timestamp: Option<MlsTime>,
+        _extensions: Option<&ExtensionList>,
+    ) -> Result<(), Self::Error> {
+        resolve_basic_identity(signing_identity).map(|_| ())
+    }
+
+    async fn validate_external_sender(
+        &self,
+        signing_identity: &SigningIdentity,
+        _timestamp: Option<MlsTime>,
+        _extensions: Option<&ExtensionList>,
+    ) -> Result<(), Self::Error> {
+        resolve_basic_identity(signing_identity).map(|_| ())
+    }
+
+    async fn identity(
+        &self,
+        signing_identity: &SigningIdentity,
+        _extensions: &ExtensionList,
+    ) -> Result<Vec<u8>, Self::Error> {
+        resolve_basic_identity(signing_identity).map(|b| b.identifier.to_vec())
+    }
+
+    async fn valid_successor(
+        &self,
+        predecessor: &SigningIdentity,
+        successor: &SigningIdentity,
+        _extensions: &ExtensionList,
+    ) -> Result<bool, Self::Error> {
+        Ok(resolve_basic_identity(predecessor)? == resolve_basic_identity(successor)?)
+    }
+
+    fn supported_types(&self) -> Vec<CredentialType> {
+        vec![BasicCredential::credential_type()]
+    }
+}
diff --git a/src/iter.rs b/src/iter.rs
new file mode 100644
index 0000000..e37f162
--- /dev/null
+++ b/src/iter.rs
@@ -0,0 +1,96 @@
+// 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(all(not(mls_build_async), feature = "rayon"))]
+mod sync_rayon {
+    use rayon::{
+        iter::IterBridge,
+        prelude::{FromParallelIterator, IntoParallelIterator, ParallelBridge, ParallelIterator},
+    };
+
+    pub fn wrap_iter<I>(it: I) -> I::Iter
+    where
+        I: IntoParallelIterator,
+    {
+        it.into_par_iter()
+    }
+
+    pub fn wrap_impl_iter<I>(it: I) -> IterBridge<I::IntoIter>
+    where
+        I: IntoIterator,
+        I::IntoIter: Send,
+        I::Item: Send,
+    {
+        it.into_iter().par_bridge()
+    }
+
+    pub trait ParallelIteratorExt {
+        type Ok: Send;
+        type Error: Send;
+
+        fn try_collect<A>(self) -> Result<A, Self::Error>
+        where
+            A: FromParallelIterator<Self::Ok>;
+    }
+
+    impl<I, T, E> ParallelIteratorExt for I
+    where
+        I: ParallelIterator<Item = Result<T, E>>,
+        T: Send,
+        E: Send,
+    {
+        type Ok = T;
+        type Error = E;
+
+        fn try_collect<A>(self) -> Result<A, Self::Error>
+        where
+            A: FromParallelIterator<Self::Ok>,
+        {
+            self.collect()
+        }
+    }
+}
+
+#[cfg(all(not(mls_build_async), feature = "rayon"))]
+pub use sync_rayon::{wrap_impl_iter, wrap_iter, ParallelIteratorExt};
+
+#[cfg(not(any(mls_build_async, feature = "rayon")))]
+mod sync {
+    pub fn wrap_iter<I>(it: I) -> I::IntoIter
+    where
+        I: IntoIterator,
+    {
+        it.into_iter()
+    }
+
+    pub fn wrap_impl_iter<I>(it: I) -> I::IntoIter
+    where
+        I: IntoIterator,
+    {
+        it.into_iter()
+    }
+}
+
+#[cfg(not(any(mls_build_async, feature = "rayon")))]
+pub use sync::{wrap_impl_iter, wrap_iter};
+
+#[cfg(mls_build_async)]
+mod async_ {
+    pub fn wrap_iter<I>(it: I) -> futures::stream::Iter<I::IntoIter>
+    where
+        I: IntoIterator,
+    {
+        futures::stream::iter(it)
+    }
+
+    pub fn wrap_impl_iter<I>(it: I) -> futures::stream::Iter<I::IntoIter>
+    where
+        I: IntoIterator,
+    {
+        futures::stream::iter(it)
+    }
+}
+
+#[cfg(mls_build_async)]
+pub use async_::{wrap_impl_iter, wrap_iter};
diff --git a/src/key_package/generator.rs b/src/key_package/generator.rs
new file mode 100644
index 0000000..4d71094
--- /dev/null
+++ b/src/key_package/generator.rs
@@ -0,0 +1,339 @@
+// 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::{error::IntoAnyError, identity::IdentityProvider, key_package::KeyPackageData};
+
+use crate::client::MlsError;
+use crate::{
+    crypto::{HpkeSecretKey, SignatureSecretKey},
+    group::framing::MlsMessagePayload,
+    identity::SigningIdentity,
+    protocol_version::ProtocolVersion,
+    signer::Signable,
+    tree_kem::{
+        leaf_node::{ConfigProperties, LeafNode},
+        Capabilities, Lifetime,
+    },
+    CipherSuiteProvider, ExtensionList, MlsMessage,
+};
+
+use super::{KeyPackage, KeyPackageRef};
+
+#[derive(Clone, Debug)]
+pub struct KeyPackageGenerator<'a, IP, CP>
+where
+    IP: IdentityProvider,
+    CP: CipherSuiteProvider,
+{
+    pub protocol_version: ProtocolVersion,
+    pub cipher_suite_provider: &'a CP,
+    pub signing_identity: &'a SigningIdentity,
+    pub signing_key: &'a SignatureSecretKey,
+    pub identity_provider: &'a IP,
+}
+
+#[derive(Clone, Debug)]
+pub struct KeyPackageGeneration {
+    pub(crate) reference: KeyPackageRef,
+    pub(crate) key_package: KeyPackage,
+    pub(crate) init_secret_key: HpkeSecretKey,
+    pub(crate) leaf_node_secret_key: HpkeSecretKey,
+}
+
+impl KeyPackageGeneration {
+    pub fn to_storage(&self) -> Result<(Vec<u8>, KeyPackageData), MlsError> {
+        let id = self.reference.to_vec();
+
+        let data = KeyPackageData::new(
+            self.key_package.mls_encode_to_vec()?,
+            self.init_secret_key.clone(),
+            self.leaf_node_secret_key.clone(),
+            self.key_package.expiration()?,
+        );
+
+        Ok((id, data))
+    }
+
+    pub fn from_storage(id: Vec<u8>, data: KeyPackageData) -> Result<Self, MlsError> {
+        Ok(KeyPackageGeneration {
+            reference: KeyPackageRef::from(id),
+            key_package: KeyPackage::mls_decode(&mut &*data.key_package_bytes)?,
+            init_secret_key: data.init_key,
+            leaf_node_secret_key: data.leaf_node_key,
+        })
+    }
+
+    pub fn key_package_message(&self) -> MlsMessage {
+        MlsMessage::new(
+            self.key_package.version,
+            MlsMessagePayload::KeyPackage(self.key_package.clone()),
+        )
+    }
+}
+
+impl<'a, IP, CP> KeyPackageGenerator<'a, IP, CP>
+where
+    IP: IdentityProvider,
+    CP: CipherSuiteProvider,
+{
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(super) async fn sign(&self, package: &mut KeyPackage) -> Result<(), MlsError> {
+        package
+            .sign(self.cipher_suite_provider, self.signing_key, &())
+            .await
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn generate(
+        &self,
+        lifetime: Lifetime,
+        capabilities: Capabilities,
+        key_package_extensions: ExtensionList,
+        leaf_node_extensions: ExtensionList,
+    ) -> Result<KeyPackageGeneration, MlsError> {
+        let (init_secret_key, public_init) = self
+            .cipher_suite_provider
+            .kem_generate()
+            .await
+            .map_err(|e| MlsError::CryptoProviderError(e.into_any_error()))?;
+
+        let properties = ConfigProperties {
+            capabilities,
+            extensions: leaf_node_extensions,
+        };
+
+        let (leaf_node, leaf_node_secret) = LeafNode::generate(
+            self.cipher_suite_provider,
+            properties,
+            self.signing_identity.clone(),
+            self.signing_key,
+            lifetime,
+        )
+        .await?;
+
+        let mut package = KeyPackage {
+            version: self.protocol_version,
+            cipher_suite: self.cipher_suite_provider.cipher_suite(),
+            hpke_init_key: public_init,
+            leaf_node,
+            extensions: key_package_extensions,
+            signature: vec![],
+        };
+
+        package.grease(self.cipher_suite_provider)?;
+
+        self.sign(&mut package).await?;
+
+        let reference = package.to_reference(self.cipher_suite_provider).await?;
+
+        Ok(KeyPackageGeneration {
+            key_package: package,
+            init_secret_key,
+            leaf_node_secret_key: leaf_node_secret,
+            reference,
+        })
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use assert_matches::assert_matches;
+    use mls_rs_core::crypto::CipherSuiteProvider;
+
+    use crate::{
+        crypto::test_utils::{test_cipher_suite_provider, TestCryptoProvider},
+        extension::test_utils::TestExtension,
+        group::test_utils::random_bytes,
+        identity::basic::BasicIdentityProvider,
+        identity::test_utils::get_test_signing_identity,
+        key_package::validate_key_package_properties,
+        protocol_version::ProtocolVersion,
+        tree_kem::{
+            leaf_node::{test_utils::get_test_capabilities, LeafNodeSource},
+            leaf_node_validator::{LeafNodeValidator, ValidationContext},
+            Lifetime,
+        },
+        ExtensionList,
+    };
+
+    use super::KeyPackageGenerator;
+
+    fn test_key_package_ext(val: u8) -> ExtensionList {
+        let mut ext_list = ExtensionList::new();
+        ext_list.set_from(TestExtension::from(val)).unwrap();
+        ext_list
+    }
+
+    fn test_leaf_node_ext(val: u8) -> ExtensionList {
+        let mut ext_list = ExtensionList::new();
+        ext_list.set_from(TestExtension::from(val)).unwrap();
+        ext_list
+    }
+
+    fn test_lifetime() -> Lifetime {
+        Lifetime::years(1).unwrap()
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_key_generation() {
+        for (protocol_version, cipher_suite) in ProtocolVersion::all().flat_map(|p| {
+            TestCryptoProvider::all_supported_cipher_suites()
+                .into_iter()
+                .map(move |cs| (p, cs))
+        }) {
+            let cipher_suite_provider = test_cipher_suite_provider(cipher_suite);
+
+            let (signing_identity, signing_key) =
+                get_test_signing_identity(cipher_suite, b"foo").await;
+
+            let key_package_ext = test_key_package_ext(32);
+            let leaf_node_ext = test_leaf_node_ext(42);
+            let lifetime = test_lifetime();
+
+            let test_generator = KeyPackageGenerator {
+                protocol_version,
+                cipher_suite_provider: &cipher_suite_provider,
+                signing_identity: &signing_identity,
+                signing_key: &signing_key,
+                identity_provider: &BasicIdentityProvider,
+            };
+
+            let mut capabilities = get_test_capabilities();
+            capabilities.extensions.push(42.into());
+            capabilities.extensions.push(43.into());
+            capabilities.extensions.push(32.into());
+
+            let generated = test_generator
+                .generate(
+                    lifetime.clone(),
+                    capabilities.clone(),
+                    key_package_ext.clone(),
+                    leaf_node_ext.clone(),
+                )
+                .await
+                .unwrap();
+
+            assert_matches!(generated.key_package.leaf_node.leaf_node_source,
+                            LeafNodeSource::KeyPackage(ref lt) if lt == &lifetime);
+
+            assert_eq!(
+                generated.key_package.leaf_node.ungreased_capabilities(),
+                capabilities
+            );
+
+            assert_eq!(
+                generated.key_package.leaf_node.ungreased_extensions(),
+                leaf_node_ext
+            );
+
+            assert_eq!(
+                generated.key_package.ungreased_extensions(),
+                key_package_ext
+            );
+
+            assert_ne!(
+                generated.key_package.hpke_init_key.as_ref(),
+                generated.key_package.leaf_node.public_key.as_ref()
+            );
+
+            assert_eq!(generated.key_package.cipher_suite, cipher_suite);
+            assert_eq!(generated.key_package.version, protocol_version);
+
+            // Verify that the hpke key pair generated will work
+            let test_data = random_bytes(32);
+
+            let sealed = cipher_suite_provider
+                .hpke_seal(&generated.key_package.hpke_init_key, &[], None, &test_data)
+                .await
+                .unwrap();
+
+            let opened = cipher_suite_provider
+                .hpke_open(
+                    &sealed,
+                    &generated.init_secret_key,
+                    &generated.key_package.hpke_init_key,
+                    &[],
+                    None,
+                )
+                .await
+                .unwrap();
+
+            assert_eq!(opened, test_data);
+
+            let validator =
+                LeafNodeValidator::new(&cipher_suite_provider, &BasicIdentityProvider, None);
+
+            validator
+                .check_if_valid(
+                    &generated.key_package.leaf_node,
+                    ValidationContext::Add(None),
+                )
+                .await
+                .unwrap();
+
+            validate_key_package_properties(
+                &generated.key_package,
+                protocol_version,
+                &cipher_suite_provider,
+            )
+            .await
+            .unwrap();
+        }
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_randomness() {
+        for (protocol_version, cipher_suite) in ProtocolVersion::all().flat_map(|p| {
+            TestCryptoProvider::all_supported_cipher_suites()
+                .into_iter()
+                .map(move |cs| (p, cs))
+        }) {
+            let (signing_identity, signing_key) =
+                get_test_signing_identity(cipher_suite, b"foo").await;
+
+            let test_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 first_key_package = test_generator
+                .generate(
+                    test_lifetime(),
+                    get_test_capabilities(),
+                    ExtensionList::default(),
+                    ExtensionList::default(),
+                )
+                .await
+                .unwrap();
+
+            for _ in 0..100 {
+                let next_key_package = test_generator
+                    .generate(
+                        test_lifetime(),
+                        get_test_capabilities(),
+                        ExtensionList::default(),
+                        ExtensionList::default(),
+                    )
+                    .await
+                    .unwrap();
+
+                assert_ne!(
+                    first_key_package.key_package.hpke_init_key,
+                    next_key_package.key_package.hpke_init_key
+                );
+
+                assert_ne!(
+                    first_key_package.key_package.leaf_node.public_key,
+                    next_key_package.key_package.leaf_node.public_key
+                );
+            }
+        }
+    }
+}
diff --git a/src/key_package/mod.rs b/src/key_package/mod.rs
new file mode 100644
index 0000000..b3ef83b
--- /dev/null
+++ b/src/key_package/mod.rs
@@ -0,0 +1,332 @@
+// 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::cipher_suite::CipherSuite;
+use crate::client::MlsError;
+use crate::crypto::HpkePublicKey;
+use crate::hash_reference::HashReference;
+use crate::identity::SigningIdentity;
+use crate::protocol_version::ProtocolVersion;
+use crate::signer::Signable;
+use crate::tree_kem::leaf_node::{LeafNode, LeafNodeSource};
+use crate::CipherSuiteProvider;
+use alloc::vec::Vec;
+use core::{
+    fmt::{self, Debug},
+    ops::Deref,
+};
+use mls_rs_codec::MlsDecode;
+use mls_rs_codec::MlsEncode;
+use mls_rs_codec::MlsSize;
+use mls_rs_core::extension::ExtensionList;
+
+mod validator;
+pub(crate) use validator::*;
+
+pub(crate) mod generator;
+pub(crate) use generator::*;
+
+#[non_exhaustive]
+#[derive(Clone, MlsSize, MlsEncode, MlsDecode, PartialEq)]
+#[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 KeyPackage {
+    pub version: ProtocolVersion,
+    pub cipher_suite: CipherSuite,
+    pub hpke_init_key: HpkePublicKey,
+    pub(crate) leaf_node: LeafNode,
+    pub extensions: ExtensionList,
+    #[mls_codec(with = "mls_rs_codec::byte_vec")]
+    #[cfg_attr(feature = "serde", serde(with = "mls_rs_core::vec_serde"))]
+    pub signature: Vec<u8>,
+}
+
+impl Debug for KeyPackage {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.debug_struct("KeyPackage")
+            .field("version", &self.version)
+            .field("cipher_suite", &self.cipher_suite)
+            .field("hpke_init_key", &self.hpke_init_key)
+            .field("leaf_node", &self.leaf_node)
+            .field("extensions", &self.extensions)
+            .field(
+                "signature",
+                &mls_rs_core::debug::pretty_bytes(&self.signature),
+            )
+            .finish()
+    }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, 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 KeyPackageRef(HashReference);
+
+impl Deref for KeyPackageRef {
+    type Target = [u8];
+
+    fn deref(&self) -> &Self::Target {
+        &self.0
+    }
+}
+
+impl From<Vec<u8>> for KeyPackageRef {
+    fn from(v: Vec<u8>) -> Self {
+        Self(HashReference::from(v))
+    }
+}
+
+#[derive(MlsSize, MlsEncode)]
+struct KeyPackageData<'a> {
+    pub version: ProtocolVersion,
+    pub cipher_suite: CipherSuite,
+    #[mls_codec(with = "mls_rs_codec::byte_vec")]
+    pub hpke_init_key: &'a HpkePublicKey,
+    pub leaf_node: &'a LeafNode,
+    pub extensions: &'a ExtensionList,
+}
+
+#[cfg_attr(all(feature = "ffi", not(test)), safer_ffi_gen::safer_ffi_gen)]
+impl KeyPackage {
+    #[cfg(feature = "ffi")]
+    pub fn version(&self) -> ProtocolVersion {
+        self.version
+    }
+
+    #[cfg(feature = "ffi")]
+    pub fn cipher_suite(&self) -> CipherSuite {
+        self.cipher_suite
+    }
+
+    pub fn signing_identity(&self) -> &SigningIdentity {
+        &self.leaf_node.signing_identity
+    }
+
+    #[cfg_attr(all(feature = "ffi", not(test)), safer_ffi_gen::safer_ffi_gen_ignore)]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn to_reference<CP: CipherSuiteProvider>(
+        &self,
+        cipher_suite_provider: &CP,
+    ) -> Result<KeyPackageRef, MlsError> {
+        if cipher_suite_provider.cipher_suite() != self.cipher_suite {
+            return Err(MlsError::CipherSuiteMismatch);
+        }
+
+        Ok(KeyPackageRef(
+            HashReference::compute(
+                &self.mls_encode_to_vec()?,
+                b"MLS 1.0 KeyPackage Reference",
+                cipher_suite_provider,
+            )
+            .await?,
+        ))
+    }
+
+    pub fn expiration(&self) -> Result<u64, MlsError> {
+        if let LeafNodeSource::KeyPackage(lifetime) = &self.leaf_node.leaf_node_source {
+            Ok(lifetime.not_after)
+        } else {
+            Err(MlsError::InvalidLeafNodeSource)
+        }
+    }
+}
+
+impl<'a> Signable<'a> for KeyPackage {
+    const SIGN_LABEL: &'static str = "KeyPackageTBS";
+
+    type SigningContext = ();
+
+    fn signature(&self) -> &[u8] {
+        &self.signature
+    }
+
+    fn signable_content(
+        &self,
+        _context: &Self::SigningContext,
+    ) -> Result<Vec<u8>, mls_rs_codec::Error> {
+        KeyPackageData {
+            version: self.version,
+            cipher_suite: self.cipher_suite,
+            hpke_init_key: &self.hpke_init_key,
+            leaf_node: &self.leaf_node,
+            extensions: &self.extensions,
+        }
+        .mls_encode_to_vec()
+    }
+
+    fn write_signature(&mut self, signature: Vec<u8>) {
+        self.signature = signature
+    }
+}
+
+#[cfg(test)]
+pub(crate) mod test_utils {
+    use super::*;
+    use crate::{
+        crypto::test_utils::test_cipher_suite_provider,
+        group::framing::MlsMessagePayload,
+        identity::basic::BasicIdentityProvider,
+        identity::test_utils::get_test_signing_identity,
+        tree_kem::{leaf_node::test_utils::get_test_capabilities, Lifetime},
+        MlsMessage,
+    };
+
+    use mls_rs_core::crypto::SignatureSecretKey;
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(crate) async fn test_key_package(
+        protocol_version: ProtocolVersion,
+        cipher_suite: CipherSuite,
+        id: &str,
+    ) -> KeyPackage {
+        test_key_package_with_signer(protocol_version, cipher_suite, id)
+            .await
+            .0
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(crate) async fn test_key_package_with_signer(
+        protocol_version: ProtocolVersion,
+        cipher_suite: CipherSuite,
+        id: &str,
+    ) -> (KeyPackage, SignatureSecretKey) {
+        let (signing_identity, secret_key) =
+            get_test_signing_identity(cipher_suite, id.as_bytes()).await;
+
+        let generator = KeyPackageGenerator {
+            protocol_version,
+            cipher_suite_provider: &test_cipher_suite_provider(cipher_suite),
+            signing_identity: &signing_identity,
+            signing_key: &secret_key,
+            identity_provider: &BasicIdentityProvider,
+        };
+
+        let key_package = generator
+            .generate(
+                Lifetime::years(1).unwrap(),
+                get_test_capabilities(),
+                ExtensionList::default(),
+                ExtensionList::default(),
+            )
+            .await
+            .unwrap()
+            .key_package;
+
+        (key_package, secret_key)
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(crate) async fn test_key_package_message(
+        protocol_version: ProtocolVersion,
+        cipher_suite: CipherSuite,
+        id: &str,
+    ) -> MlsMessage {
+        MlsMessage::new(
+            protocol_version,
+            MlsMessagePayload::KeyPackage(
+                test_key_package(protocol_version, cipher_suite, id).await,
+            ),
+        )
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use crate::{
+        client::test_utils::{TEST_CIPHER_SUITE, TEST_PROTOCOL_VERSION},
+        crypto::test_utils::{test_cipher_suite_provider, try_test_cipher_suite_provider},
+    };
+
+    use super::{test_utils::test_key_package, *};
+    use alloc::format;
+    use assert_matches::assert_matches;
+
+    #[derive(serde::Deserialize, serde::Serialize)]
+    struct TestCase {
+        cipher_suite: u16,
+        #[serde(with = "hex::serde")]
+        input: Vec<u8>,
+        #[serde(with = "hex::serde")]
+        output: Vec<u8>,
+    }
+
+    impl TestCase {
+        #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+        #[cfg_attr(coverage_nightly, coverage(off))]
+        async fn generate() -> Vec<TestCase> {
+            let mut test_cases = Vec::new();
+
+            for (i, (protocol_version, cipher_suite)) in ProtocolVersion::all()
+                .flat_map(|p| CipherSuite::all().map(move |cs| (p, cs)))
+                .enumerate()
+            {
+                let pkg =
+                    test_key_package(protocol_version, cipher_suite, &format!("alice{i}")).await;
+
+                let pkg_ref = pkg
+                    .to_reference(&test_cipher_suite_provider(cipher_suite))
+                    .await
+                    .unwrap();
+
+                let case = TestCase {
+                    cipher_suite: cipher_suite.into(),
+                    input: pkg.mls_encode_to_vec().unwrap(),
+                    output: pkg_ref.to_vec(),
+                };
+
+                test_cases.push(case);
+            }
+
+            test_cases
+        }
+    }
+
+    #[cfg(mls_build_async)]
+    async fn load_test_cases() -> Vec<TestCase> {
+        load_test_case_json!(key_package_ref, TestCase::generate().await)
+    }
+
+    #[cfg(not(mls_build_async))]
+    fn load_test_cases() -> Vec<TestCase> {
+        load_test_case_json!(key_package_ref, TestCase::generate())
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_key_package_ref() {
+        let cases = load_test_cases().await;
+
+        for one_case in cases {
+            let Some(provider) = try_test_cipher_suite_provider(one_case.cipher_suite) else {
+                continue;
+            };
+
+            let key_package = KeyPackage::mls_decode(&mut one_case.input.as_slice()).unwrap();
+
+            let key_package_ref = key_package.to_reference(&provider).await.unwrap();
+
+            let expected_out = KeyPackageRef::from(one_case.output);
+            assert_eq!(expected_out, key_package_ref);
+        }
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn key_package_ref_fails_invalid_cipher_suite() {
+        let key_package = test_key_package(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, "test").await;
+
+        for another_cipher_suite in CipherSuite::all().filter(|cs| cs != &TEST_CIPHER_SUITE) {
+            if let Some(cs) = try_test_cipher_suite_provider(*another_cipher_suite) {
+                let res = key_package.to_reference(&cs).await;
+
+                assert_matches!(res, Err(MlsError::CipherSuiteMismatch));
+            }
+        }
+    }
+}
diff --git a/src/key_package/validator.rs b/src/key_package/validator.rs
new file mode 100644
index 0000000..9cf1dae
--- /dev/null
+++ b/src/key_package/validator.rs
@@ -0,0 +1,39 @@
+// 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::CipherSuiteProvider, protocol_version::ProtocolVersion};
+
+use crate::{client::MlsError, signer::Signable, KeyPackage};
+
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+pub(crate) async fn validate_key_package_properties<CSP: CipherSuiteProvider>(
+    package: &KeyPackage,
+    version: ProtocolVersion,
+    cs: &CSP,
+) -> Result<(), MlsError> {
+    package
+        .verify(cs, &package.leaf_node.signing_identity.signature_key, &())
+        .await?;
+
+    // Verify that the protocol version matches
+    if package.version != version {
+        return Err(MlsError::ProtocolVersionMismatch);
+    }
+
+    // Verify that the cipher suite matches
+    if package.cipher_suite != cs.cipher_suite() {
+        return Err(MlsError::CipherSuiteMismatch);
+    }
+
+    // Verify that the public init key is a valid format for this cipher suite
+    cs.kem_public_key_validate(&package.hpke_init_key)
+        .map_err(|_| MlsError::InvalidInitKey)?;
+
+    // Verify that the init key and the leaf node public key are different
+    if package.hpke_init_key.as_ref() == package.leaf_node.public_key.as_ref() {
+        return Err(MlsError::InitLeafKeyEquality);
+    }
+
+    Ok(())
+}
diff --git a/src/lib.rs b/src/lib.rs
new file mode 100644
index 0000000..115b3f8
--- /dev/null
+++ b/src/lib.rs
@@ -0,0 +1,218 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// Copyright by contributors to this project.
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+
+//! An implementation of the [IETF Messaging Layer Security](https://messaginglayersecurity.rocks)
+//! end-to-end encryption (E2EE) protocol.
+//!
+//! ## What is MLS?
+//!
+//! MLS is a new IETF end-to-end encryption standard that is designed to
+//! provide transport agnostic, asynchronous, and highly performant
+//! communication between a group of clients.
+//!
+//! ## MLS Protocol Features
+//!
+//! - Multi-party E2EE [group evolution](https://www.rfc-editor.org/rfc/rfc9420.html#name-cryptographic-state-and-evo)
+//! via a propose-then-commit mechanism.
+//! - Asynchronous by design with pre-computed [key packages](https://www.rfc-editor.org/rfc/rfc9420.html#name-key-packages),
+//!   allowing members to be added to a group while offline.
+//! - Customizable credential system with built in support for X.509 certificates.
+//! - [Extension system](https://www.rfc-editor.org/rfc/rfc9420.html#name-extensions)
+//!   allowing for application specific data to be negotiated via the protocol.
+//! - Strong forward secrecy and post compromise security.
+//! - Crypto agility via support for multiple [cipher suites](https://www.rfc-editor.org/rfc/rfc9420.html#name-cipher-suites).
+//! - Pre-shared key support.
+//! - Subgroup branching.
+//! - Group reinitialization for breaking changes such as protocol upgrades.
+//!
+//! ## Features
+//!
+//! - Easy to use client interface that can manage multiple MLS identities and groups.
+//! - 100% RFC 9420 conformance with support for all default credential, proposal,
+//!   and extension types.
+//! - Support for WASM builds.
+//! - Configurable storage for key packages, secrets and group state
+//!   via traits along with provided "in memory" and SQLite implementations.
+//! - Support for custom user proposal and extension types.
+//! - Ability to create user defined credentials with custom validation
+//!   routines that can bridge to existing credential schemes.
+//! - OpenSSL and Rust Crypto based cipher suite implementations.
+//! - Crypto agility with support for user defined cipher suite.
+//! - Extensive test suite including security and interop focused tests against
+//!   pre-computed test vectors.
+//!
+//! ## Crypto Providers
+//!
+//! For cipher suite descriptions see the RFC documentation [here](https://www.rfc-editor.org/rfc/rfc9420.html#name-mls-cipher-suites)
+//!
+//! | Name | Cipher Suites | X509 Support |
+//! |------|---------------|--------------|
+//! | OpenSSL | 1-7 | Stable |
+//! | AWS-LC | 1,2,3,5,7 | Stable |
+//! | Rust Crypto | 1,2,3 | ⚠️ Experimental |
+//!
+//! ## Security Notice
+//!
+//! This library has been validated for conformance to the RFC 9420 specification but has not yet received a full security audit by a 3rd party.
+
+#![allow(clippy::enum_variant_names)]
+#![allow(clippy::result_large_err)]
+#![allow(clippy::nonstandard_macro_braces)]
+#![cfg_attr(not(feature = "std"), no_std)]
+#![cfg_attr(docsrs, feature(doc_cfg))]
+#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
+extern crate alloc;
+
+#[cfg(all(test, target_arch = "wasm32"))]
+wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
+
+#[cfg(all(test, target_arch = "wasm32"))]
+use wasm_bindgen_test::wasm_bindgen_test as futures_test;
+
+#[cfg(all(test, mls_build_async, not(target_arch = "wasm32")))]
+use futures_test::test as futures_test;
+
+#[cfg(test)]
+macro_rules! hex {
+    ($input:literal) => {
+        hex::decode($input).expect("invalid hex value")
+    };
+}
+
+#[cfg(test)]
+macro_rules! load_test_case_json {
+    ($name:ident, $generate:expr) => {
+        load_test_case_json!($name, $generate, to_vec_pretty)
+    };
+    ($name:ident, $generate:expr, $to_json:ident) => {{
+        #[cfg(any(target_arch = "wasm32", not(feature = "std")))]
+        {
+            // Do not remove `async`! (The goal of this line is to remove warnings
+            // about `$generate` not being used. Actually calling it will make tests fail.)
+            let _ = async { $generate };
+            serde_json::from_slice(include_bytes!(concat!(
+                env!("CARGO_MANIFEST_DIR"),
+                "/test_data/",
+                stringify!($name),
+                ".json"
+            )))
+            .unwrap()
+        }
+
+        #[cfg(all(not(target_arch = "wasm32"), feature = "std"))]
+        {
+            let path = concat!(
+                env!("CARGO_MANIFEST_DIR"),
+                "/test_data/",
+                stringify!($name),
+                ".json"
+            );
+            if !std::path::Path::new(path).exists() {
+                std::fs::write(path, serde_json::$to_json(&$generate).unwrap()).unwrap();
+            }
+            serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap()
+        }
+    }};
+}
+
+mod cipher_suite {
+    pub use mls_rs_core::crypto::CipherSuite;
+}
+
+pub use cipher_suite::CipherSuite;
+
+mod protocol_version {
+    pub use mls_rs_core::protocol_version::ProtocolVersion;
+}
+
+pub use protocol_version::ProtocolVersion;
+
+pub mod client;
+pub mod client_builder;
+mod client_config;
+/// Dependencies of [`CryptoProvider`] and [`CipherSuiteProvider`]
+pub mod crypto;
+/// Extension utilities and built-in extension types.
+pub mod extension;
+/// Tools to observe groups without being a member, useful
+/// for server implementations.
+#[cfg(feature = "external_client")]
+#[cfg_attr(docsrs, doc(cfg(feature = "external_client")))]
+pub mod external_client;
+mod grease;
+/// E2EE group created by a [`Client`].
+pub mod group;
+mod hash_reference;
+/// Identity providers to use with [`ClientBuilder`](client_builder::ClientBuilder).
+pub mod identity;
+mod iter;
+mod key_package;
+/// Pre-shared key support.
+pub mod psk;
+mod signer;
+/// Storage providers to use with
+/// [`ClientBuilder`](client_builder::ClientBuilder).
+pub mod storage_provider;
+
+pub use mls_rs_core::{
+    crypto::{CipherSuiteProvider, CryptoProvider},
+    group::GroupStateStorage,
+    identity::IdentityProvider,
+    key_package::KeyPackageStorage,
+    psk::PreSharedKeyStorage,
+};
+
+/// Dependencies of [`MlsRules`].
+pub mod mls_rules {
+    pub use crate::group::{
+        mls_rules::{
+            CommitDirection, CommitOptions, CommitSource, DefaultMlsRules, EncryptionOptions,
+        },
+        proposal_filter::{ProposalBundle, ProposalInfo, ProposalSource},
+    };
+
+    #[cfg(feature = "by_ref_proposal")]
+    pub use crate::group::proposal_ref::ProposalRef;
+}
+
+pub use mls_rs_core::extension::{Extension, ExtensionList};
+
+pub use crate::{
+    client::Client,
+    group::{
+        framing::{MlsMessage, WireFormat},
+        mls_rules::MlsRules,
+        Group,
+    },
+    key_package::{KeyPackage, KeyPackageRef},
+};
+
+/// Error types.
+pub mod error {
+    pub use crate::client::MlsError;
+    pub use mls_rs_core::error::{AnyError, IntoAnyError};
+    pub use mls_rs_core::extension::ExtensionError;
+}
+
+/// WASM compatible timestamp.
+pub mod time {
+    pub use mls_rs_core::time::*;
+}
+
+mod tree_kem;
+
+pub use mls_rs_codec;
+
+mod private {
+    pub trait Sealed {}
+}
+
+use private::Sealed;
+
+#[cfg(any(test, feature = "test_util"))]
+#[doc(hidden)]
+pub mod test_utils;
+
+#[cfg(feature = "ffi")]
+pub use safer_ffi_gen;
diff --git a/src/message.rs b/src/message.rs
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/message.rs
diff --git a/src/psk.rs b/src/psk.rs
new file mode 100644
index 0000000..5bf95c3
--- /dev/null
+++ b/src/psk.rs
@@ -0,0 +1,200 @@
+// 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;
+
+#[cfg(any(test, feature = "external_client"))]
+use alloc::vec;
+
+use mls_rs_codec::{MlsDecode, MlsEncode, MlsSize};
+
+#[cfg(any(test, feature = "external_client"))]
+use mls_rs_core::psk::PreSharedKeyStorage;
+
+#[cfg(any(test, feature = "external_client"))]
+use core::convert::Infallible;
+use core::fmt::{self, Debug};
+
+#[cfg(feature = "psk")]
+use crate::{client::MlsError, CipherSuiteProvider};
+
+#[cfg(feature = "psk")]
+use mls_rs_core::error::IntoAnyError;
+
+#[cfg(feature = "psk")]
+pub(crate) mod resolver;
+pub(crate) mod secret;
+
+pub use mls_rs_core::psk::{ExternalPskId, PreSharedKey};
+
+#[derive(Clone, Debug, Eq, Hash, PartialEq, PartialOrd, Ord, MlsSize, MlsEncode, MlsDecode)]
+#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+pub(crate) struct PreSharedKeyID {
+    pub key_id: JustPreSharedKeyID,
+    pub psk_nonce: PskNonce,
+}
+
+impl PreSharedKeyID {
+    #[cfg(feature = "psk")]
+    pub(crate) fn new<P: CipherSuiteProvider>(
+        key_id: JustPreSharedKeyID,
+        cs: &P,
+    ) -> Result<Self, MlsError> {
+        Ok(Self {
+            key_id,
+            psk_nonce: PskNonce::random(cs)
+                .map_err(|e| MlsError::CryptoProviderError(e.into_any_error()))?,
+        })
+    }
+}
+
+#[derive(Clone, Debug, Eq, Hash, Ord, PartialOrd, 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 JustPreSharedKeyID {
+    External(ExternalPskId) = 1u8,
+    Resumption(ResumptionPsk) = 2u8,
+}
+
+#[derive(Clone, Eq, Hash, Ord, PartialOrd, PartialEq, MlsSize, MlsEncode, MlsDecode)]
+#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+pub(crate) struct PskGroupId(
+    #[mls_codec(with = "mls_rs_codec::byte_vec")]
+    #[cfg_attr(feature = "serde", serde(with = "mls_rs_core::vec_serde"))]
+    pub Vec<u8>,
+);
+
+impl Debug for PskGroupId {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        mls_rs_core::debug::pretty_bytes(&self.0)
+            .named("PskGroupId")
+            .fmt(f)
+    }
+}
+
+#[derive(Clone, Eq, Hash, PartialEq, PartialOrd, Ord, MlsSize, MlsEncode, MlsDecode)]
+#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+pub(crate) struct PskNonce(
+    #[mls_codec(with = "mls_rs_codec::byte_vec")]
+    #[cfg_attr(feature = "serde", serde(with = "mls_rs_core::vec_serde"))]
+    pub Vec<u8>,
+);
+
+impl Debug for PskNonce {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        mls_rs_core::debug::pretty_bytes(&self.0)
+            .named("PskNonce")
+            .fmt(f)
+    }
+}
+
+#[cfg(feature = "psk")]
+impl PskNonce {
+    pub fn random<P: CipherSuiteProvider>(
+        cipher_suite_provider: &P,
+    ) -> Result<Self, <P as CipherSuiteProvider>::Error> {
+        Ok(Self(cipher_suite_provider.random_bytes_vec(
+            cipher_suite_provider.kdf_extract_size(),
+        )?))
+    }
+}
+
+#[derive(Clone, Debug, Eq, Hash, Ord, PartialOrd, PartialEq, MlsSize, MlsEncode, MlsDecode)]
+#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+pub(crate) struct ResumptionPsk {
+    pub usage: ResumptionPSKUsage,
+    pub psk_group_id: PskGroupId,
+    pub psk_epoch: u64,
+}
+
+#[derive(Clone, Debug, Eq, Hash, PartialEq, Ord, PartialOrd, MlsSize, MlsEncode, MlsDecode)]
+#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[repr(u8)]
+pub(crate) enum ResumptionPSKUsage {
+    Application = 1u8,
+    Reinit = 2u8,
+    Branch = 3u8,
+}
+
+#[derive(Clone, Debug, PartialEq, MlsSize, MlsEncode)]
+struct PSKLabel<'a> {
+    id: &'a PreSharedKeyID,
+    index: u16,
+    count: u16,
+}
+
+#[cfg(any(test, feature = "external_client"))]
+#[derive(Clone, Copy, Debug)]
+pub(crate) struct AlwaysFoundPskStorage;
+
+#[cfg(any(test, feature = "external_client"))]
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+#[cfg_attr(mls_build_async, maybe_async::must_be_async)]
+impl PreSharedKeyStorage for AlwaysFoundPskStorage {
+    type Error = Infallible;
+
+    async fn get(&self, _: &ExternalPskId) -> Result<Option<PreSharedKey>, Self::Error> {
+        Ok(Some(vec![].into()))
+    }
+}
+
+#[cfg(feature = "psk")]
+#[cfg(test)]
+pub(crate) mod test_utils {
+    use crate::crypto::test_utils::test_cipher_suite_provider;
+
+    use super::PskNonce;
+    use mls_rs_core::crypto::CipherSuite;
+
+    #[cfg(not(mls_build_async))]
+    use mls_rs_core::{crypto::CipherSuiteProvider, psk::ExternalPskId};
+
+    #[cfg_attr(coverage_nightly, coverage(off))]
+    #[cfg(not(mls_build_async))]
+    pub(crate) fn make_external_psk_id<P: CipherSuiteProvider>(
+        cipher_suite_provider: &P,
+    ) -> ExternalPskId {
+        ExternalPskId::new(
+            cipher_suite_provider
+                .random_bytes_vec(cipher_suite_provider.kdf_extract_size())
+                .unwrap(),
+        )
+    }
+
+    pub(crate) fn make_nonce(cipher_suite: CipherSuite) -> PskNonce {
+        PskNonce::random(&test_cipher_suite_provider(cipher_suite)).unwrap()
+    }
+}
+
+#[cfg(feature = "psk")]
+#[cfg(test)]
+mod tests {
+    use crate::crypto::test_utils::TestCryptoProvider;
+    use core::iter;
+
+    #[cfg(target_arch = "wasm32")]
+    use wasm_bindgen_test::wasm_bindgen_test as test;
+
+    use super::test_utils::make_nonce;
+
+    #[test]
+    fn random_generation_of_nonces_is_random() {
+        let good = TestCryptoProvider::all_supported_cipher_suites()
+            .into_iter()
+            .all(|cipher_suite| {
+                let nonce = make_nonce(cipher_suite);
+                iter::repeat_with(|| make_nonce(cipher_suite))
+                    .take(1000)
+                    .all(|other| other != nonce)
+            });
+
+        assert!(good);
+    }
+}
diff --git a/src/psk/resolver.rs b/src/psk/resolver.rs
new file mode 100644
index 0000000..0e3b7c9
--- /dev/null
+++ b/src/psk/resolver.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 mls_rs_core::{
+    crypto::CipherSuiteProvider,
+    error::IntoAnyError,
+    group::GroupStateStorage,
+    key_package::KeyPackageStorage,
+    psk::{ExternalPskId, PreSharedKey, PreSharedKeyStorage},
+};
+
+use crate::{
+    client::MlsError,
+    group::{epoch::EpochSecrets, state_repo::GroupStateRepository, GroupContext},
+    psk::secret::PskSecret,
+};
+
+use super::{secret::PskSecretInput, JustPreSharedKeyID, PreSharedKeyID, ResumptionPsk};
+
+pub(crate) struct PskResolver<'a, GS, K, PS>
+where
+    GS: GroupStateStorage,
+    PS: PreSharedKeyStorage,
+    K: KeyPackageStorage,
+{
+    pub group_context: Option<&'a GroupContext>,
+    pub current_epoch: Option<&'a EpochSecrets>,
+    pub prior_epochs: Option<&'a GroupStateRepository<GS, K>>,
+    pub psk_store: &'a PS,
+}
+
+impl<GS: GroupStateStorage, K: KeyPackageStorage, PS: PreSharedKeyStorage>
+    PskResolver<'_, GS, K, PS>
+{
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn resolve_resumption(&self, psk_id: &ResumptionPsk) -> Result<PreSharedKey, MlsError> {
+        if let Some(ctx) = self.group_context {
+            if ctx.epoch == psk_id.psk_epoch && ctx.group_id == psk_id.psk_group_id.0 {
+                let epoch = self.current_epoch.ok_or(MlsError::OldGroupStateNotFound)?;
+                return Ok(epoch.resumption_secret.clone());
+            }
+        }
+
+        #[cfg(feature = "prior_epoch")]
+        if let Some(eps) = self.prior_epochs {
+            if let Some(psk) = eps.resumption_secret(psk_id).await? {
+                return Ok(psk);
+            }
+        }
+
+        Err(MlsError::OldGroupStateNotFound)
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn resolve_external(&self, psk_id: &ExternalPskId) -> Result<PreSharedKey, MlsError> {
+        self.psk_store
+            .get(psk_id)
+            .await
+            .map_err(|e| MlsError::PskStoreError(e.into_any_error()))?
+            .ok_or(MlsError::MissingRequiredPsk)
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn resolve(&self, id: &[PreSharedKeyID]) -> Result<Vec<PskSecretInput>, MlsError> {
+        let mut secret_inputs = Vec::new();
+
+        for id in id {
+            let psk = match &id.key_id {
+                JustPreSharedKeyID::External(external) => self.resolve_external(external).await,
+                JustPreSharedKeyID::Resumption(resumption) => {
+                    self.resolve_resumption(resumption).await
+                }
+            }?;
+
+            secret_inputs.push(PskSecretInput {
+                id: id.clone(),
+                psk,
+            })
+        }
+
+        Ok(secret_inputs)
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn resolve_to_secret<P: CipherSuiteProvider>(
+        &self,
+        id: &[PreSharedKeyID],
+        cipher_suite_provider: &P,
+    ) -> Result<PskSecret, MlsError> {
+        let psk = self.resolve(id).await?;
+        PskSecret::calculate(&psk, cipher_suite_provider).await
+    }
+}
diff --git a/src/psk/secret.rs b/src/psk/secret.rs
new file mode 100644
index 0000000..4fe9cc8
--- /dev/null
+++ b/src/psk/secret.rs
@@ -0,0 +1,239 @@
+// 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},
+    ops::Deref,
+};
+use mls_rs_core::crypto::CipherSuiteProvider;
+use zeroize::Zeroizing;
+
+#[cfg(feature = "psk")]
+use mls_rs_codec::MlsEncode;
+
+#[cfg(feature = "psk")]
+use mls_rs_core::{error::IntoAnyError, psk::PreSharedKey};
+
+#[cfg(feature = "psk")]
+use crate::{
+    client::MlsError,
+    group::key_schedule::kdf_expand_with_label,
+    psk::{PSKLabel, PreSharedKeyID},
+};
+
+#[cfg(feature = "psk")]
+#[derive(Clone)]
+pub(crate) struct PskSecretInput {
+    pub id: PreSharedKeyID,
+    pub psk: PreSharedKey,
+}
+
+#[derive(PartialEq, Eq, Clone)]
+pub(crate) struct PskSecret(Zeroizing<Vec<u8>>);
+
+impl Debug for PskSecret {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        mls_rs_core::debug::pretty_bytes(&self.0)
+            .named("PskSecret")
+            .fmt(f)
+    }
+}
+
+#[cfg(test)]
+impl From<Vec<u8>> for PskSecret {
+    fn from(value: Vec<u8>) -> Self {
+        PskSecret(Zeroizing::new(value))
+    }
+}
+
+impl Deref for PskSecret {
+    type Target = [u8];
+
+    fn deref(&self) -> &Self::Target {
+        &self.0
+    }
+}
+
+impl PskSecret {
+    pub(crate) fn new<P: CipherSuiteProvider>(provider: &P) -> PskSecret {
+        PskSecret(Zeroizing::new(vec![0u8; provider.kdf_extract_size()]))
+    }
+
+    #[cfg(feature = "psk")]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(crate) async fn calculate<P: CipherSuiteProvider>(
+        input: &[PskSecretInput],
+        cipher_suite_provider: &P,
+    ) -> Result<PskSecret, MlsError> {
+        let len = u16::try_from(input.len()).map_err(|_| MlsError::TooManyPskIds)?;
+        let mut psk_secret = PskSecret::new(cipher_suite_provider);
+
+        for (index, psk_secret_input) in input.iter().enumerate() {
+            let index = index as u16;
+
+            let label = PSKLabel {
+                id: &psk_secret_input.id,
+                index,
+                count: len,
+            };
+
+            let psk_extracted = cipher_suite_provider
+                .kdf_extract(
+                    &vec![0; cipher_suite_provider.kdf_extract_size()],
+                    &psk_secret_input.psk,
+                )
+                .await
+                .map_err(|e| MlsError::CryptoProviderError(e.into_any_error()))?;
+
+            let psk_input = kdf_expand_with_label(
+                cipher_suite_provider,
+                &psk_extracted,
+                b"derived psk",
+                &label.mls_encode_to_vec()?,
+                None,
+            )
+            .await?;
+
+            psk_secret = cipher_suite_provider
+                .kdf_extract(&psk_input, &psk_secret)
+                .await
+                .map(PskSecret)
+                .map_err(|e| MlsError::CryptoProviderError(e.into_any_error()))?;
+        }
+
+        Ok(psk_secret)
+    }
+}
+
+#[cfg(feature = "psk")]
+#[cfg(test)]
+mod tests {
+    use alloc::vec::Vec;
+    #[cfg(not(mls_build_async))]
+    use core::iter;
+    use serde::{Deserialize, Serialize};
+
+    use crate::{
+        crypto::test_utils::try_test_cipher_suite_provider,
+        psk::ExternalPskId,
+        psk::{JustPreSharedKeyID, PreSharedKeyID, PskNonce},
+        CipherSuiteProvider,
+    };
+
+    #[cfg(not(mls_build_async))]
+    use crate::{
+        crypto::test_utils::test_cipher_suite_provider, psk::test_utils::make_external_psk_id,
+        CipherSuite,
+    };
+
+    use super::{PskSecret, PskSecretInput};
+
+    #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
+    struct PskInfo {
+        #[serde(with = "hex::serde")]
+        id: Vec<u8>,
+        #[serde(with = "hex::serde")]
+        psk: Vec<u8>,
+        #[serde(with = "hex::serde")]
+        nonce: Vec<u8>,
+    }
+
+    impl From<PskInfo> for PskSecretInput {
+        fn from(info: PskInfo) -> Self {
+            let id = PreSharedKeyID {
+                key_id: JustPreSharedKeyID::External(ExternalPskId::new(info.id)),
+                psk_nonce: PskNonce(info.nonce),
+            };
+
+            PskSecretInput {
+                id,
+                psk: info.psk.into(),
+            }
+        }
+    }
+
+    #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
+    struct TestScenario {
+        cipher_suite: u16,
+        psks: Vec<PskInfo>,
+        #[serde(with = "hex::serde")]
+        psk_secret: Vec<u8>,
+    }
+
+    impl TestScenario {
+        #[cfg_attr(coverage_nightly, coverage(off))]
+        #[cfg(not(mls_build_async))]
+        fn make_psk_list<CS: CipherSuiteProvider>(cs: &CS, n: usize) -> Vec<PskInfo> {
+            iter::repeat_with(
+                #[cfg_attr(coverage_nightly, coverage(off))]
+                || PskInfo {
+                    id: make_external_psk_id(cs).to_vec(),
+                    psk: cs.random_bytes_vec(cs.kdf_extract_size()).unwrap(),
+                    nonce: crate::psk::test_utils::make_nonce(cs.cipher_suite()).0,
+                },
+            )
+            .take(n)
+            .collect::<Vec<_>>()
+        }
+
+        #[cfg(not(mls_build_async))]
+        #[cfg_attr(coverage_nightly, coverage(off))]
+        fn generate() -> Vec<TestScenario> {
+            CipherSuite::all()
+                .flat_map(
+                    #[cfg_attr(coverage_nightly, coverage(off))]
+                    |cs| (1..=10).map(move |n| (cs, n)),
+                )
+                .map(
+                    #[cfg_attr(coverage_nightly, coverage(off))]
+                    |(cs, n)| {
+                        let provider = test_cipher_suite_provider(cs);
+                        let psks = Self::make_psk_list(&provider, n);
+                        let psk_secret = Self::compute_psk_secret(&provider, psks.clone());
+                        TestScenario {
+                            cipher_suite: cs.into(),
+                            psks: psks.to_vec(),
+                            psk_secret: psk_secret.to_vec(),
+                        }
+                    },
+                )
+                .collect()
+        }
+
+        #[cfg(mls_build_async)]
+        fn generate() -> Vec<TestScenario> {
+            panic!("Tests cannot be generated in async mode");
+        }
+
+        #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+        async fn compute_psk_secret<P: CipherSuiteProvider>(
+            provider: &P,
+            psks: Vec<PskInfo>,
+        ) -> PskSecret {
+            let input = psks
+                .into_iter()
+                .map(PskSecretInput::from)
+                .collect::<Vec<_>>();
+
+            PskSecret::calculate(&input, provider).await.unwrap()
+        }
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn expected_psk_secret_is_produced() {
+        let scenarios: Vec<TestScenario> =
+            load_test_case_json!(psk_secret, TestScenario::generate());
+
+        for scenario in scenarios {
+            if let Some(provider) = try_test_cipher_suite_provider(scenario.cipher_suite) {
+                let computed =
+                    TestScenario::compute_psk_secret(&provider, scenario.psks.clone()).await;
+
+                assert_eq!(scenario.psk_secret, computed.to_vec());
+            }
+        }
+    }
+}
diff --git a/src/signer.rs b/src/signer.rs
new file mode 100644
index 0000000..12970ec
--- /dev/null
+++ b/src/signer.rs
@@ -0,0 +1,357 @@
+// 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::{MlsEncode, MlsSize};
+use mls_rs_core::error::IntoAnyError;
+
+use crate::client::MlsError;
+use crate::crypto::{CipherSuiteProvider, SignaturePublicKey, SignatureSecretKey};
+
+#[derive(Clone, MlsSize, MlsEncode)]
+struct SignContent {
+    #[mls_codec(with = "mls_rs_codec::byte_vec")]
+    label: Vec<u8>,
+    #[mls_codec(with = "mls_rs_codec::byte_vec")]
+    content: Vec<u8>,
+}
+
+impl Debug for SignContent {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.debug_struct("SignContent")
+            .field("label", &mls_rs_core::debug::pretty_bytes(&self.label))
+            .field("content", &mls_rs_core::debug::pretty_bytes(&self.content))
+            .finish()
+    }
+}
+
+impl SignContent {
+    pub fn new(label: &str, content: Vec<u8>) -> Self {
+        Self {
+            label: [b"MLS 1.0 ", label.as_bytes()].concat(),
+            content,
+        }
+    }
+}
+
+#[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 Signable<'a> {
+    const SIGN_LABEL: &'static str;
+
+    type SigningContext: Send + Sync;
+
+    fn signature(&self) -> &[u8];
+
+    fn signable_content(
+        &self,
+        context: &Self::SigningContext,
+    ) -> Result<Vec<u8>, mls_rs_codec::Error>;
+
+    fn write_signature(&mut self, signature: Vec<u8>);
+
+    async fn sign<P: CipherSuiteProvider>(
+        &mut self,
+        signature_provider: &P,
+        signer: &SignatureSecretKey,
+        context: &Self::SigningContext,
+    ) -> Result<(), MlsError> {
+        let sign_content = SignContent::new(Self::SIGN_LABEL, self.signable_content(context)?);
+
+        let signature = signature_provider
+            .sign(signer, &sign_content.mls_encode_to_vec()?)
+            .await
+            .map_err(|e| MlsError::CryptoProviderError(e.into_any_error()))?;
+
+        self.write_signature(signature);
+
+        Ok(())
+    }
+
+    async fn verify<P: CipherSuiteProvider>(
+        &self,
+        signature_provider: &P,
+        public_key: &SignaturePublicKey,
+        context: &Self::SigningContext,
+    ) -> Result<(), MlsError> {
+        let sign_content = SignContent::new(Self::SIGN_LABEL, self.signable_content(context)?);
+
+        signature_provider
+            .verify(
+                public_key,
+                self.signature(),
+                &sign_content.mls_encode_to_vec()?,
+            )
+            .await
+            .map_err(|_| MlsError::InvalidSignature)
+    }
+}
+
+#[cfg(test)]
+pub(crate) mod test_utils {
+    use alloc::vec;
+    use alloc::{string::String, vec::Vec};
+    use mls_rs_core::crypto::CipherSuiteProvider;
+
+    use crate::crypto::test_utils::try_test_cipher_suite_provider;
+
+    use super::Signable;
+
+    #[derive(Debug, serde::Serialize, serde::Deserialize)]
+    pub struct SignatureInteropTestCase {
+        #[serde(with = "hex::serde", rename = "priv")]
+        secret: Vec<u8>,
+        #[serde(with = "hex::serde", rename = "pub")]
+        public: Vec<u8>,
+        #[serde(with = "hex::serde")]
+        content: Vec<u8>,
+        label: String,
+        #[serde(with = "hex::serde")]
+        signature: Vec<u8>,
+    }
+
+    #[derive(Debug, serde::Serialize, serde::Deserialize)]
+    pub struct InteropTestCase {
+        cipher_suite: u16,
+        sign_with_label: SignatureInteropTestCase,
+    }
+
+    #[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.sign_with_label.verify(&cs).await;
+            }
+        }
+    }
+
+    pub struct TestSignable {
+        pub content: Vec<u8>,
+        pub signature: Vec<u8>,
+    }
+
+    impl<'a> Signable<'a> for TestSignable {
+        const SIGN_LABEL: &'static str = "SignWithLabel";
+
+        type SigningContext = Vec<u8>;
+
+        fn signature(&self) -> &[u8] {
+            &self.signature
+        }
+
+        fn signable_content(
+            &self,
+            context: &Self::SigningContext,
+        ) -> Result<Vec<u8>, mls_rs_codec::Error> {
+            Ok([context.as_slice(), self.content.as_slice()].concat())
+        }
+
+        fn write_signature(&mut self, signature: Vec<u8>) {
+            self.signature = signature
+        }
+    }
+
+    impl SignatureInteropTestCase {
+        #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+        pub async fn verify<P: CipherSuiteProvider>(&self, cs: &P) {
+            let public = self.public.clone().into();
+
+            let signable = TestSignable {
+                content: self.content.clone(),
+                signature: self.signature.clone(),
+            };
+
+            signable.verify(cs, &public, &vec![]).await.unwrap();
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::{test_utils::TestSignable, *};
+    use crate::{
+        client::test_utils::TEST_CIPHER_SUITE,
+        crypto::test_utils::{
+            test_cipher_suite_provider, try_test_cipher_suite_provider, TestCryptoProvider,
+        },
+        group::test_utils::random_bytes,
+    };
+    use alloc::vec;
+    use assert_matches::assert_matches;
+
+    #[derive(Debug, serde::Serialize, serde::Deserialize)]
+    struct TestCase {
+        cipher_suite: u16,
+        #[serde(with = "hex::serde")]
+        content: Vec<u8>,
+        #[serde(with = "hex::serde")]
+        context: Vec<u8>,
+        #[serde(with = "hex::serde")]
+        signature: Vec<u8>,
+        #[serde(with = "hex::serde")]
+        signer: Vec<u8>,
+        #[serde(with = "hex::serde")]
+        public: Vec<u8>,
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    #[cfg_attr(coverage_nightly, coverage(off))]
+    async fn generate_test_cases() -> Vec<TestCase> {
+        let mut test_cases = Vec::new();
+
+        for cipher_suite in TestCryptoProvider::all_supported_cipher_suites() {
+            let provider = test_cipher_suite_provider(cipher_suite);
+
+            let (signer, public) = provider.signature_key_generate().await.unwrap();
+
+            let content = random_bytes(32);
+            let context = random_bytes(32);
+
+            let mut test_signable = TestSignable {
+                content: content.clone(),
+                signature: Vec::new(),
+            };
+
+            test_signable
+                .sign(&provider, &signer, &context)
+                .await
+                .unwrap();
+
+            test_cases.push(TestCase {
+                cipher_suite: cipher_suite.into(),
+                content,
+                context,
+                signature: test_signable.signature,
+                signer: signer.to_vec(),
+                public: public.to_vec(),
+            });
+        }
+
+        test_cases
+    }
+
+    #[cfg(mls_build_async)]
+    async fn load_test_cases() -> Vec<TestCase> {
+        load_test_case_json!(signatures, generate_test_cases().await)
+    }
+
+    #[cfg(not(mls_build_async))]
+    fn load_test_cases() -> Vec<TestCase> {
+        load_test_case_json!(signatures, generate_test_cases())
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_signatures() {
+        let cases = load_test_cases().await;
+
+        for one_case in cases {
+            let Some(cipher_suite_provider) = try_test_cipher_suite_provider(one_case.cipher_suite)
+            else {
+                continue;
+            };
+
+            let public_key = SignaturePublicKey::from(one_case.public);
+
+            // Wasm uses incompatible signature secret key format
+            #[cfg(not(target_arch = "wasm32"))]
+            {
+                // Test signature generation
+                let mut test_signable = TestSignable {
+                    content: one_case.content.clone(),
+                    signature: Vec::new(),
+                };
+
+                let signature_key = SignatureSecretKey::from(one_case.signer);
+
+                test_signable
+                    .sign(&cipher_suite_provider, &signature_key, &one_case.context)
+                    .await
+                    .unwrap();
+
+                test_signable
+                    .verify(&cipher_suite_provider, &public_key, &one_case.context)
+                    .await
+                    .unwrap();
+            }
+
+            // Test verifying an existing signature
+            let test_signable = TestSignable {
+                content: one_case.content,
+                signature: one_case.signature,
+            };
+
+            test_signable
+                .verify(&cipher_suite_provider, &public_key, &one_case.context)
+                .await
+                .unwrap();
+        }
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_invalid_signature() {
+        let cipher_suite_provider = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+
+        let (correct_secret, _) = cipher_suite_provider
+            .signature_key_generate()
+            .await
+            .unwrap();
+        let (_, incorrect_public) = cipher_suite_provider
+            .signature_key_generate()
+            .await
+            .unwrap();
+
+        let mut test_signable = TestSignable {
+            content: random_bytes(32),
+            signature: vec![],
+        };
+
+        test_signable
+            .sign(&cipher_suite_provider, &correct_secret, &vec![])
+            .await
+            .unwrap();
+
+        let res = test_signable
+            .verify(&cipher_suite_provider, &incorrect_public, &vec![])
+            .await;
+
+        assert_matches!(res, Err(MlsError::InvalidSignature));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_invalid_context() {
+        let cipher_suite_provider = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+
+        let (secret, public) = cipher_suite_provider
+            .signature_key_generate()
+            .await
+            .unwrap();
+
+        let correct_context = random_bytes(32);
+        let incorrect_context = random_bytes(32);
+
+        let mut test_signable = TestSignable {
+            content: random_bytes(32),
+            signature: vec![],
+        };
+
+        test_signable
+            .sign(&cipher_suite_provider, &secret, &correct_context)
+            .await
+            .unwrap();
+
+        let res = test_signable
+            .verify(&cipher_suite_provider, &public, &incorrect_context)
+            .await;
+
+        assert_matches!(res, Err(MlsError::InvalidSignature));
+    }
+}
diff --git a/src/storage_provider.rs b/src/storage_provider.rs
new file mode 100644
index 0000000..ffe8cd9
--- /dev/null
+++ b/src/storage_provider.rs
@@ -0,0 +1,14 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// Copyright by contributors to this project.
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+
+/// Storage providers that operate completely in memory.
+pub mod in_memory;
+pub(crate) mod key_package;
+
+pub use key_package::*;
+
+#[cfg(feature = "sqlite")]
+#[cfg_attr(docsrs, doc(cfg(feature = "sqlite")))]
+/// SQLite based storage providers.
+pub mod sqlite;
diff --git a/src/storage_provider/group_state.rs b/src/storage_provider/group_state.rs
new file mode 100644
index 0000000..b6c854d
--- /dev/null
+++ b/src/storage_provider/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 alloc::vec::Vec;
+use mls_rs_codec::MlsEncode;
+pub use mls_rs_core::group::{EpochRecord, GroupState};
+
+use crate::group::snapshot::Snapshot;
+
+#[cfg(feature = "prior_epoch")]
+use crate::group::epoch::PriorEpoch;
+
+#[cfg(feature = "prior_epoch")]
+impl EpochRecord for PriorEpoch {
+    fn id(&self) -> u64 {
+        self.epoch_id()
+    }
+}
+
+impl GroupState for Snapshot {
+    fn id(&self) -> Vec<u8> {
+        self.group_id().to_vec()
+    }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) struct EpochData {
+    pub(crate) id: u64,
+    pub(crate) data: Vec<u8>,
+}
+
+impl EpochData {
+    pub(crate) fn new<T>(value: T) -> Result<Self, mls_rs_codec::Error>
+    where
+        T: MlsEncode + EpochRecord,
+    {
+        Ok(Self {
+            id: value.id(),
+            data: value.mls_encode_to_vec()?,
+        })
+    }
+}
diff --git a/src/storage_provider/in_memory.rs b/src/storage_provider/in_memory.rs
new file mode 100644
index 0000000..cb8f5d7
--- /dev/null
+++ b/src/storage_provider/in_memory.rs
@@ -0,0 +1,11 @@
+// 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 group_state_storage;
+mod key_package_storage;
+mod psk_storage;
+
+pub use group_state_storage::*;
+pub use key_package_storage::*;
+pub use psk_storage::*;
diff --git a/src/storage_provider/in_memory/group_state_storage.rs b/src/storage_provider/in_memory/group_state_storage.rs
new file mode 100644
index 0000000..5999ed0
--- /dev/null
+++ b/src/storage_provider/in_memory/group_state_storage.rs
@@ -0,0 +1,354 @@
+// 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::collections::VecDeque;
+
+#[cfg(target_has_atomic = "ptr")]
+use alloc::sync::Arc;
+
+#[cfg(mls_build_async)]
+use alloc::boxed::Box;
+use alloc::vec::Vec;
+use core::{
+    convert::Infallible,
+    fmt::{self, Debug},
+};
+use mls_rs_core::group::{EpochRecord, GroupState, GroupStateStorage};
+#[cfg(not(target_has_atomic = "ptr"))]
+use portable_atomic_util::Arc;
+
+use crate::client::MlsError;
+
+#[cfg(feature = "std")]
+use std::collections::{hash_map::Entry, HashMap};
+
+#[cfg(not(feature = "std"))]
+use alloc::collections::{btree_map::Entry, BTreeMap};
+
+#[cfg(feature = "std")]
+use std::sync::Mutex;
+
+#[cfg(not(feature = "std"))]
+use spin::Mutex;
+
+pub(crate) const DEFAULT_EPOCH_RETENTION_LIMIT: usize = 3;
+
+#[derive(Clone)]
+pub(crate) struct InMemoryGroupData {
+    pub(crate) state_data: Vec<u8>,
+    pub(crate) epoch_data: VecDeque<EpochRecord>,
+}
+
+impl Debug for InMemoryGroupData {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.debug_struct("InMemoryGroupData")
+            .field(
+                "state_data",
+                &mls_rs_core::debug::pretty_bytes(&self.state_data),
+            )
+            .field("epoch_data", &self.epoch_data)
+            .finish()
+    }
+}
+
+impl InMemoryGroupData {
+    pub fn new(state_data: Vec<u8>) -> InMemoryGroupData {
+        InMemoryGroupData {
+            state_data,
+            epoch_data: Default::default(),
+        }
+    }
+
+    fn get_epoch_data_index(&self, epoch_id: u64) -> Option<u64> {
+        self.epoch_data
+            .front()
+            .and_then(|e| epoch_id.checked_sub(e.id))
+    }
+
+    pub fn get_epoch(&self, epoch_id: u64) -> Option<&EpochRecord> {
+        self.get_epoch_data_index(epoch_id)
+            .and_then(|i| self.epoch_data.get(i as usize))
+    }
+
+    pub fn get_mut_epoch(&mut self, epoch_id: u64) -> Option<&mut EpochRecord> {
+        self.get_epoch_data_index(epoch_id)
+            .and_then(|i| self.epoch_data.get_mut(i as usize))
+    }
+
+    pub fn insert_epoch(&mut self, epoch: EpochRecord) {
+        self.epoch_data.push_back(epoch)
+    }
+
+    // This function does not fail if an update can't be made. If the epoch
+    // is not in the store, then it can no longer be accessed by future
+    // get_epoch calls and is no longer relevant.
+    pub fn update_epoch(&mut self, epoch: EpochRecord) {
+        if let Some(existing_epoch) = self.get_mut_epoch(epoch.id) {
+            *existing_epoch = epoch
+        }
+    }
+
+    pub fn trim_epochs(&mut self, max_epoch_retention: usize) {
+        while self.epoch_data.len() > max_epoch_retention {
+            self.epoch_data.pop_front();
+        }
+    }
+}
+
+#[derive(Clone)]
+/// In memory group state storage backed by a HashMap.
+///
+/// All clones of an instance of this type share the same underlying HashMap.
+pub struct InMemoryGroupStateStorage {
+    #[cfg(feature = "std")]
+    pub(crate) inner: Arc<Mutex<HashMap<Vec<u8>, InMemoryGroupData>>>,
+    #[cfg(not(feature = "std"))]
+    pub(crate) inner: Arc<Mutex<BTreeMap<Vec<u8>, InMemoryGroupData>>>,
+    pub(crate) max_epoch_retention: usize,
+}
+
+impl Debug for InMemoryGroupStateStorage {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.debug_struct("InMemoryGroupStateStorage")
+            .field(
+                "inner",
+                &mls_rs_core::debug::pretty_with(|f| {
+                    f.debug_map()
+                        .entries(
+                            self.lock()
+                                .iter()
+                                .map(|(k, v)| (mls_rs_core::debug::pretty_bytes(k), v)),
+                        )
+                        .finish()
+                }),
+            )
+            .field("max_epoch_retention", &self.max_epoch_retention)
+            .finish()
+    }
+}
+
+impl InMemoryGroupStateStorage {
+    /// Create an empty group state storage.
+    pub fn new() -> Self {
+        Self {
+            inner: Default::default(),
+            max_epoch_retention: DEFAULT_EPOCH_RETENTION_LIMIT,
+        }
+    }
+
+    pub fn with_max_epoch_retention(self, max_epoch_retention: usize) -> Result<Self, MlsError> {
+        (max_epoch_retention > 0)
+            .then_some(())
+            .ok_or(MlsError::NonZeroRetentionRequired)?;
+
+        Ok(Self {
+            inner: self.inner,
+            max_epoch_retention,
+        })
+    }
+
+    /// Get the set of unique group ids that have data stored.
+    pub fn stored_groups(&self) -> Vec<Vec<u8>> {
+        self.lock().keys().cloned().collect()
+    }
+
+    /// Delete all data corresponding to `group_id`.
+    pub fn delete_group(&self, group_id: &[u8]) {
+        self.lock().remove(group_id);
+    }
+
+    #[cfg(feature = "std")]
+    fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<Vec<u8>, InMemoryGroupData>> {
+        self.inner.lock().unwrap()
+    }
+
+    #[cfg(not(feature = "std"))]
+    fn lock(&self) -> spin::mutex::MutexGuard<'_, BTreeMap<Vec<u8>, InMemoryGroupData>> {
+        self.inner.lock()
+    }
+}
+
+impl Default for InMemoryGroupStateStorage {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+#[cfg_attr(mls_build_async, maybe_async::must_be_async)]
+impl GroupStateStorage for InMemoryGroupStateStorage {
+    type Error = Infallible;
+
+    async fn max_epoch_id(&self, group_id: &[u8]) -> Result<Option<u64>, Self::Error> {
+        Ok(self
+            .lock()
+            .get(group_id)
+            .and_then(|group_data| group_data.epoch_data.back().map(|e| e.id)))
+    }
+
+    async fn state(&self, group_id: &[u8]) -> Result<Option<Vec<u8>>, Self::Error> {
+        Ok(self
+            .lock()
+            .get(group_id)
+            .map(|data| data.state_data.clone()))
+    }
+
+    async fn epoch(&self, group_id: &[u8], epoch_id: u64) -> Result<Option<Vec<u8>>, Self::Error> {
+        Ok(self
+            .lock()
+            .get(group_id)
+            .and_then(|data| data.get_epoch(epoch_id).map(|ep| ep.data.clone())))
+    }
+
+    async fn write(
+        &mut self,
+        state: GroupState,
+        epoch_inserts: Vec<EpochRecord>,
+        epoch_updates: Vec<EpochRecord>,
+    ) -> Result<(), Self::Error> {
+        let mut group_map = self.lock();
+
+        let group_data = match group_map.entry(state.id) {
+            Entry::Occupied(entry) => {
+                let data = entry.into_mut();
+                data.state_data = state.data;
+                data
+            }
+            Entry::Vacant(entry) => entry.insert(InMemoryGroupData::new(state.data)),
+        };
+
+        epoch_inserts
+            .into_iter()
+            .for_each(|e| group_data.insert_epoch(e));
+
+        epoch_updates
+            .into_iter()
+            .for_each(|e| group_data.update_epoch(e));
+
+        group_data.trim_epochs(self.max_epoch_retention);
+
+        Ok(())
+    }
+}
+
+#[cfg(all(test, feature = "prior_epoch"))]
+mod tests {
+    use alloc::{format, vec, vec::Vec};
+    use assert_matches::assert_matches;
+
+    use super::{InMemoryGroupData, InMemoryGroupStateStorage};
+    use crate::{client::MlsError, group::test_utils::TEST_GROUP};
+
+    use mls_rs_core::group::{EpochRecord, GroupState, GroupStateStorage};
+
+    impl InMemoryGroupStateStorage {
+        fn test_data(&self) -> InMemoryGroupData {
+            self.lock().get(TEST_GROUP).unwrap().clone()
+        }
+    }
+
+    fn test_storage(retention_limit: usize) -> Result<InMemoryGroupStateStorage, MlsError> {
+        InMemoryGroupStateStorage::new().with_max_epoch_retention(retention_limit)
+    }
+
+    fn test_epoch(epoch_id: u64) -> EpochRecord {
+        EpochRecord::new(epoch_id, format!("epoch {epoch_id}").as_bytes().to_vec())
+    }
+
+    fn test_snapshot(epoch_id: u64) -> GroupState {
+        GroupState {
+            id: TEST_GROUP.into(),
+            data: format!("snapshot {epoch_id}").as_bytes().to_vec(),
+        }
+    }
+
+    #[test]
+    fn test_zero_max_retention() {
+        assert_matches!(test_storage(0), Err(MlsError::NonZeroRetentionRequired))
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn existing_storage_can_have_larger_epoch_count() {
+        let mut storage = test_storage(2).unwrap();
+
+        let epoch_inserts = vec![test_epoch(0), test_epoch(1)];
+
+        storage
+            .write(test_snapshot(0), epoch_inserts, Vec::new())
+            .await
+            .unwrap();
+
+        assert_eq!(storage.test_data().epoch_data.len(), 2);
+
+        storage.max_epoch_retention = 4;
+
+        let epoch_inserts = vec![test_epoch(3), test_epoch(4)];
+
+        storage
+            .write(test_snapshot(1), epoch_inserts, Vec::new())
+            .await
+            .unwrap();
+
+        assert_eq!(storage.test_data().epoch_data.len(), 4);
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn existing_storage_can_have_smaller_epoch_count() {
+        let mut storage = test_storage(4).unwrap();
+
+        let epoch_inserts = vec![test_epoch(0), test_epoch(1), test_epoch(3), test_epoch(4)];
+
+        storage
+            .write(test_snapshot(1), epoch_inserts, Vec::new())
+            .await
+            .unwrap();
+
+        assert_eq!(storage.test_data().epoch_data.len(), 4);
+
+        storage.max_epoch_retention = 2;
+
+        let epoch_inserts = vec![test_epoch(5)];
+
+        storage
+            .write(test_snapshot(1), epoch_inserts, Vec::new())
+            .await
+            .unwrap();
+
+        assert_eq!(storage.test_data().epoch_data.len(), 2);
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn epoch_insert_over_limit() {
+        test_epoch_insert_over_limit(false).await
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn epoch_insert_over_limit_with_update() {
+        test_epoch_insert_over_limit(true).await
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn test_epoch_insert_over_limit(with_update: bool) {
+        let mut storage = test_storage(1).unwrap();
+
+        let mut epoch_inserts = vec![test_epoch(0), test_epoch(1)];
+        let updates = with_update
+            .then_some(vec![test_epoch(0)])
+            .unwrap_or_default();
+        let snapshot = test_snapshot(1);
+
+        storage
+            .write(snapshot.clone(), epoch_inserts.clone(), updates)
+            .await
+            .unwrap();
+
+        let stored = storage.test_data();
+
+        assert_eq!(stored.state_data, snapshot.data);
+        assert_eq!(stored.epoch_data.len(), 1);
+
+        let expected = epoch_inserts.pop().unwrap();
+        assert_eq!(stored.epoch_data[0], expected);
+    }
+}
diff --git a/src/storage_provider/in_memory/key_package_storage.rs b/src/storage_provider/in_memory/key_package_storage.rs
new file mode 100644
index 0000000..427a8a4
--- /dev/null
+++ b/src/storage_provider/in_memory/key_package_storage.rs
@@ -0,0 +1,120 @@
+// 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(target_has_atomic = "ptr")]
+use alloc::sync::Arc;
+
+#[cfg(not(target_has_atomic = "ptr"))]
+use portable_atomic_util::Arc;
+
+use core::{
+    convert::Infallible,
+    fmt::{self, Debug},
+};
+
+#[cfg(feature = "std")]
+use std::collections::HashMap;
+
+#[cfg(not(feature = "std"))]
+use alloc::collections::BTreeMap;
+use alloc::vec::Vec;
+use mls_rs_core::key_package::{KeyPackageData, KeyPackageStorage};
+
+#[cfg(feature = "std")]
+use std::sync::Mutex;
+
+#[cfg(mls_build_async)]
+use alloc::boxed::Box;
+#[cfg(not(feature = "std"))]
+use spin::Mutex;
+
+#[derive(Clone, Default)]
+/// In memory key package storage backed by a HashMap.
+///
+/// All clones of an instance of this type share the same underlying HashMap.
+pub struct InMemoryKeyPackageStorage {
+    #[cfg(feature = "std")]
+    inner: Arc<Mutex<HashMap<Vec<u8>, KeyPackageData>>>,
+    #[cfg(not(feature = "std"))]
+    inner: Arc<Mutex<BTreeMap<Vec<u8>, KeyPackageData>>>,
+}
+
+impl Debug for InMemoryKeyPackageStorage {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.debug_struct("InMemoryKeyPackageStorage")
+            .field(
+                "inner",
+                &mls_rs_core::debug::pretty_with(|f| {
+                    f.debug_map()
+                        .entries(
+                            self.lock()
+                                .iter()
+                                .map(|(k, v)| (mls_rs_core::debug::pretty_bytes(k), v)),
+                        )
+                        .finish()
+                }),
+            )
+            .finish()
+    }
+}
+
+impl InMemoryKeyPackageStorage {
+    /// Create an empty key package storage.
+    pub fn new() -> Self {
+        Default::default()
+    }
+
+    /// Insert key package data.
+    pub fn insert(&self, id: Vec<u8>, pkg: KeyPackageData) {
+        self.lock().insert(id, pkg);
+    }
+
+    /// Get a key package data by `id`.
+    pub fn get(&self, id: &[u8]) -> Option<KeyPackageData> {
+        self.lock().get(id).cloned()
+    }
+
+    /// Delete key package data by `id`.
+    pub fn delete(&self, id: &[u8]) {
+        self.lock().remove(id);
+    }
+
+    /// Get all key packages that are currently stored.
+    pub fn key_packages(&self) -> Vec<(Vec<u8>, KeyPackageData)> {
+        self.lock()
+            .iter()
+            .map(|(k, v)| (k.clone(), v.clone()))
+            .collect()
+    }
+
+    #[cfg(feature = "std")]
+    fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<Vec<u8>, KeyPackageData>> {
+        self.inner.lock().unwrap()
+    }
+
+    #[cfg(not(feature = "std"))]
+    fn lock(&self) -> spin::mutex::MutexGuard<'_, BTreeMap<Vec<u8>, KeyPackageData>> {
+        self.inner.lock()
+    }
+}
+
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+#[cfg_attr(mls_build_async, maybe_async::must_be_async)]
+impl KeyPackageStorage for InMemoryKeyPackageStorage {
+    type Error = Infallible;
+
+    async fn delete(&mut self, id: &[u8]) -> Result<(), Self::Error> {
+        (*self).delete(id);
+        Ok(())
+    }
+
+    async fn insert(&mut self, id: Vec<u8>, pkg: KeyPackageData) -> Result<(), Self::Error> {
+        (*self).insert(id, pkg);
+        Ok(())
+    }
+
+    async fn get(&self, id: &[u8]) -> Result<Option<KeyPackageData>, Self::Error> {
+        Ok(self.get(id))
+    }
+}
diff --git a/src/storage_provider/in_memory/psk_storage.rs b/src/storage_provider/in_memory/psk_storage.rs
new file mode 100644
index 0000000..e1b0b75
--- /dev/null
+++ b/src/storage_provider/in_memory/psk_storage.rs
@@ -0,0 +1,83 @@
+// 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(target_has_atomic = "ptr")]
+use alloc::sync::Arc;
+
+#[cfg(not(target_has_atomic = "ptr"))]
+use portable_atomic_util::Arc;
+
+use core::convert::Infallible;
+
+#[cfg(feature = "std")]
+use std::collections::HashMap;
+
+#[cfg(not(feature = "std"))]
+use alloc::collections::BTreeMap;
+
+use mls_rs_core::psk::{ExternalPskId, PreSharedKey, PreSharedKeyStorage};
+
+#[cfg(mls_build_async)]
+use alloc::boxed::Box;
+#[cfg(feature = "std")]
+use std::sync::Mutex;
+
+#[cfg(not(feature = "std"))]
+use spin::Mutex;
+
+#[derive(Clone, Debug, Default)]
+/// In memory pre-shared key storage backed by a HashMap.
+///
+/// All clones of an instance of this type share the same underlying HashMap.
+pub struct InMemoryPreSharedKeyStorage {
+    #[cfg(feature = "std")]
+    inner: Arc<Mutex<HashMap<ExternalPskId, PreSharedKey>>>,
+    #[cfg(not(feature = "std"))]
+    inner: Arc<Mutex<BTreeMap<ExternalPskId, PreSharedKey>>>,
+}
+
+impl InMemoryPreSharedKeyStorage {
+    /// Insert a pre-shared key into storage.
+    pub fn insert(&mut self, id: ExternalPskId, psk: PreSharedKey) {
+        #[cfg(feature = "std")]
+        let mut lock = self.inner.lock().unwrap();
+
+        #[cfg(not(feature = "std"))]
+        let mut lock = self.inner.lock();
+
+        lock.insert(id, psk);
+    }
+
+    /// Get a pre-shared key by `id`.
+    pub fn get(&self, id: &ExternalPskId) -> Option<PreSharedKey> {
+        #[cfg(feature = "std")]
+        let lock = self.inner.lock().unwrap();
+
+        #[cfg(not(feature = "std"))]
+        let lock = self.inner.lock();
+
+        lock.get(id).cloned()
+    }
+
+    /// Delete a pre-shared key from storage.
+    pub fn delete(&mut self, id: &ExternalPskId) {
+        #[cfg(feature = "std")]
+        let mut lock = self.inner.lock().unwrap();
+
+        #[cfg(not(feature = "std"))]
+        let mut lock = self.inner.lock();
+
+        lock.remove(id);
+    }
+}
+
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+#[cfg_attr(mls_build_async, maybe_async::must_be_async)]
+impl PreSharedKeyStorage for InMemoryPreSharedKeyStorage {
+    type Error = Infallible;
+
+    async fn get(&self, id: &ExternalPskId) -> Result<Option<PreSharedKey>, Self::Error> {
+        Ok(self.get(id))
+    }
+}
diff --git a/src/storage_provider/key_package.rs b/src/storage_provider/key_package.rs
new file mode 100644
index 0000000..1e209fb
--- /dev/null
+++ b/src/storage_provider/key_package.rs
@@ -0,0 +1,5 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// Copyright by contributors to this project.
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+
+pub use mls_rs_core::key_package::KeyPackageData;
diff --git a/src/storage_provider/sqlite.rs b/src/storage_provider/sqlite.rs
new file mode 100644
index 0000000..f4e4f1f
--- /dev/null
+++ b/src/storage_provider/sqlite.rs
@@ -0,0 +1,5 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// Copyright by contributors to this project.
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+
+pub use mls_rs_provider_sqlite::*;
diff --git a/src/test_utils/benchmarks.rs b/src/test_utils/benchmarks.rs
new file mode 100644
index 0000000..93d8964
--- /dev/null
+++ b/src/test_utils/benchmarks.rs
@@ -0,0 +1,140 @@
+use mls_rs_codec::MlsEncode;
+use mls_rs_core::protocol_version::ProtocolVersion;
+
+use crate::{
+    cipher_suite::CipherSuite,
+    client_builder::{BaseConfig, MlsConfig, WithCryptoProvider, WithIdentityProvider},
+    group::{framing::MlsMessage, Group},
+    identity::basic::BasicIdentityProvider,
+    test_utils::{generate_basic_client, get_test_groups},
+};
+
+pub use mls_rs_crypto_openssl::OpensslCryptoProvider as MlsCryptoProvider;
+
+pub type TestClientConfig =
+    WithIdentityProvider<BasicIdentityProvider, WithCryptoProvider<MlsCryptoProvider, BaseConfig>>;
+
+macro_rules! load_test_case_mls {
+    ($name:ident, $generate:expr) => {
+        load_test_case_mls!($name, $generate, to_vec_pretty)
+    };
+    ($name:ident, $generate:expr, $to_json:ident) => {{
+        #[cfg(any(target_arch = "wasm32", not(feature = "std")))]
+        {
+            // Do not remove `async`! (The goal of this line is to remove warnings
+            // about `$generate` not being used. Actually calling it will make tests fail.)
+            let _ = async { $generate };
+
+            mls_rs_codec::MlsDecode::mls_decode(&mut &include_bytes!(concat!(
+                env!("CARGO_MANIFEST_DIR"),
+                "/test_data/",
+                stringify!($name),
+                ".mls"
+            )))
+            .unwrap()
+        }
+
+        #[cfg(all(not(target_arch = "wasm32"), feature = "std"))]
+        {
+            let path = concat!(
+                env!("CARGO_MANIFEST_DIR"),
+                "/test_data/",
+                stringify!($name),
+                ".mls"
+            );
+
+            if !std::path::Path::new(path).exists() {
+                std::fs::write(path, $generate.mls_encode_to_vec().unwrap()).unwrap();
+            }
+
+            mls_rs_codec::MlsDecode::mls_decode(&mut std::fs::read(path).unwrap().as_slice())
+                .unwrap()
+        }
+    }};
+}
+
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+async fn generate_test_cases(cs: CipherSuite) -> Vec<MlsMessage> {
+    let mut cases = Vec::new();
+
+    for size in [16, 64, 128] {
+        let group = get_test_groups(
+            ProtocolVersion::MLS_10,
+            cs,
+            size,
+            None,
+            false,
+            &MlsCryptoProvider::new(),
+        )
+        .await
+        .pop()
+        .unwrap();
+
+        let group_info = group
+            .group_info_message_allowing_ext_commit(true)
+            .await
+            .unwrap();
+
+        cases.push(group_info)
+    }
+
+    cases
+}
+
+#[derive(Clone)]
+pub struct GroupStates<C: MlsConfig> {
+    pub sender: Group<C>,
+    pub receiver: Group<C>,
+}
+
+#[cfg(mls_build_async)]
+pub fn load_group_states(cs: CipherSuite) -> Vec<GroupStates<impl MlsConfig>> {
+    let group_info = load_test_case_mls!(group_state, block_on(generate_test_cases(cs)), to_vec);
+    join_group(cs, group_info)
+}
+
+#[cfg(not(mls_build_async))]
+pub fn load_group_states(cs: CipherSuite) -> Vec<GroupStates<impl MlsConfig>> {
+    let group_infos: Vec<MlsMessage> =
+        load_test_case_mls!(group_state, generate_test_cases(cs), to_vec);
+
+    group_infos
+        .into_iter()
+        .map(|info| join_group(cs, info))
+        .collect()
+}
+
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+pub async fn join_group(cs: CipherSuite, group_info: MlsMessage) -> GroupStates<impl MlsConfig> {
+    let client = generate_basic_client(
+        cs,
+        ProtocolVersion::MLS_10,
+        99999999999,
+        None,
+        false,
+        &MlsCryptoProvider::new(),
+        None,
+    );
+
+    let mut sender = client.commit_external(group_info).await.unwrap().0;
+
+    let client = generate_basic_client(
+        cs,
+        ProtocolVersion::MLS_10,
+        99999999998,
+        None,
+        false,
+        &MlsCryptoProvider::new(),
+        None,
+    );
+
+    let group_info = sender
+        .group_info_message_allowing_ext_commit(true)
+        .await
+        .unwrap();
+
+    let (receiver, commit) = client.commit_external(group_info).await.unwrap();
+    sender.process_incoming_message(commit).await.unwrap();
+
+    GroupStates { sender, receiver }
+}
diff --git a/src/test_utils/fuzz_tests.rs b/src/test_utils/fuzz_tests.rs
new file mode 100644
index 0000000..9ec143e
--- /dev/null
+++ b/src/test_utils/fuzz_tests.rs
@@ -0,0 +1,109 @@
+use std::sync::Mutex;
+
+use mls_rs_core::{
+    crypto::{CipherSuiteProvider, CryptoProvider, SignatureSecretKey},
+    identity::BasicCredential,
+};
+
+use once_cell::sync::Lazy;
+
+use crate::{
+    cipher_suite::CipherSuite,
+    client::MlsError,
+    client_builder::{BaseConfig, WithCryptoProvider, WithIdentityProvider},
+    group::{
+        framing::{Content, MlsMessage, Sender, WireFormat},
+        message_processor::MessageProcessor,
+        message_signature::AuthenticatedContent,
+        Commit, Group,
+    },
+    identity::{basic::BasicIdentityProvider, SigningIdentity},
+    Client, ExtensionList,
+};
+
+#[cfg(awslc)]
+pub use mls_rs_crypto_awslc::AwsLcCryptoProvider as MlsCryptoProvider;
+#[cfg(not(any(awslc, rustcrypto)))]
+pub use mls_rs_crypto_openssl::OpensslCryptoProvider as MlsCryptoProvider;
+#[cfg(rustcrypto)]
+pub use mls_rs_crypto_rustcrypto::RustCryptoProvider as MlsCryptoProvider;
+
+pub type TestClientConfig =
+    WithIdentityProvider<BasicIdentityProvider, WithCryptoProvider<MlsCryptoProvider, BaseConfig>>;
+
+pub static GROUP: Lazy<Mutex<Group<TestClientConfig>>> = Lazy::new(|| Mutex::new(create_group()));
+
+pub fn create_group() -> Group<TestClientConfig> {
+    let cipher_suite = CipherSuite::CURVE25519_AES128;
+    let alice = make_client(cipher_suite, "alice");
+    let bob = make_client(cipher_suite, "bob");
+
+    let mut alice = alice.create_group(ExtensionList::new()).unwrap();
+
+    alice
+        .commit_builder()
+        .add_member(bob.generate_key_package_message().unwrap())
+        .unwrap()
+        .build()
+        .unwrap();
+
+    alice.apply_pending_commit().unwrap();
+
+    alice
+}
+
+pub fn create_fuzz_commit_message(
+    group_id: Vec<u8>,
+    epoch: u64,
+    authenticated_data: Vec<u8>,
+) -> Result<MlsMessage, MlsError> {
+    let mut group = GROUP.lock().unwrap();
+
+    let mut context = group.context().clone();
+    context.group_id = group_id;
+    context.epoch = epoch;
+
+    #[cfg(feature = "private_message")]
+    let wire_format = WireFormat::PrivateMessage;
+
+    #[cfg(not(feature = "private_message"))]
+    let wire_format = WireFormat::PublicMessage;
+
+    let auth_content = AuthenticatedContent::new_signed(
+        group.cipher_suite_provider(),
+        &context,
+        Sender::Member(0),
+        Content::Commit(alloc::boxed::Box::new(Commit {
+            proposals: Vec::new(),
+            path: None,
+        })),
+        &group.signer,
+        wire_format,
+        authenticated_data,
+    )?;
+
+    group.format_for_wire(auth_content)
+}
+
+fn make_client(cipher_suite: CipherSuite, name: &str) -> Client<TestClientConfig> {
+    let (secret, signing_identity) = make_identity(cipher_suite, name);
+
+    // TODO : consider fuzzing on encrypted controls (doesn't seem very useful)
+    Client::builder()
+        .identity_provider(BasicIdentityProvider)
+        .crypto_provider(MlsCryptoProvider::default())
+        .signing_identity(signing_identity, secret, cipher_suite)
+        .build()
+}
+
+fn make_identity(cipher_suite: CipherSuite, name: &str) -> (SignatureSecretKey, SigningIdentity) {
+    let cipher_suite = MlsCryptoProvider::new()
+        .cipher_suite_provider(cipher_suite)
+        .unwrap();
+
+    let (secret, public) = cipher_suite.signature_key_generate().unwrap();
+    let basic_identity = BasicCredential::new(name.as_bytes().to_vec());
+    let signing_identity = SigningIdentity::new(basic_identity.into_credential(), public);
+
+    (secret, signing_identity)
+}
diff --git a/src/test_utils/mod.rs b/src/test_utils/mod.rs
new file mode 100644
index 0000000..d7c238b
--- /dev/null
+++ b/src/test_utils/mod.rs
@@ -0,0 +1,184 @@
+// 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(all(feature = "benchmark_util", not(mls_build_async)))]
+pub mod benchmarks;
+
+#[cfg(all(feature = "fuzz_util", not(mls_build_async)))]
+pub mod fuzz_tests;
+
+use mls_rs_core::{
+    crypto::{CipherSuite, CipherSuiteProvider, CryptoProvider},
+    identity::{BasicCredential, Credential, SigningIdentity},
+    protocol_version::ProtocolVersion,
+    psk::ExternalPskId,
+};
+
+use crate::{
+    client_builder::{ClientBuilder, MlsConfig},
+    identity::basic::BasicIdentityProvider,
+    mls_rules::{CommitOptions, DefaultMlsRules},
+    tree_kem::Lifetime,
+    Client, Group, MlsMessage,
+};
+
+#[cfg(feature = "private_message")]
+use crate::group::{mls_rules::EncryptionOptions, padding::PaddingMode};
+
+use alloc::{vec, vec::Vec};
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+pub fn get_test_basic_credential(identity: Vec<u8>) -> Credential {
+    BasicCredential::new(identity).into_credential()
+}
+
+pub const TEST_EXT_PSK_ID: &[u8] = b"external psk";
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+pub fn make_test_ext_psk() -> Vec<u8> {
+    b"secret psk key".to_vec()
+}
+
+pub fn is_edwards(cs: u16) -> bool {
+    [
+        CipherSuite::CURVE25519_AES128,
+        CipherSuite::CURVE25519_CHACHA,
+        CipherSuite::CURVE448_AES256,
+        CipherSuite::CURVE448_CHACHA,
+    ]
+    .contains(&cs.into())
+}
+
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+#[cfg_attr(coverage_nightly, coverage(off))]
+pub async fn generate_basic_client<C: CryptoProvider + Clone>(
+    cipher_suite: CipherSuite,
+    protocol_version: ProtocolVersion,
+    id: usize,
+    commit_options: Option<CommitOptions>,
+    #[cfg(feature = "private_message")] encrypt_controls: bool,
+    #[cfg(not(feature = "private_message"))] _encrypt_controls: bool,
+    crypto: &C,
+    lifetime: Option<Lifetime>,
+) -> Client<impl MlsConfig> {
+    let cs = crypto.cipher_suite_provider(cipher_suite).unwrap();
+
+    let (secret_key, public_key) = cs.signature_key_generate().await.unwrap();
+    let credential = get_test_basic_credential(alloc::format!("{id}").into_bytes());
+
+    let identity = SigningIdentity::new(credential, public_key);
+
+    let mls_rules =
+        DefaultMlsRules::default().with_commit_options(commit_options.unwrap_or_default());
+
+    #[cfg(feature = "private_message")]
+    let mls_rules = if encrypt_controls {
+        mls_rules.with_encryption_options(EncryptionOptions::new(true, PaddingMode::None))
+    } else {
+        mls_rules
+    };
+
+    let mut builder = ClientBuilder::new()
+        .crypto_provider(crypto.clone())
+        .identity_provider(BasicIdentityProvider::new())
+        .mls_rules(mls_rules)
+        .psk(
+            ExternalPskId::new(TEST_EXT_PSK_ID.to_vec()),
+            make_test_ext_psk().into(),
+        )
+        .used_protocol_version(protocol_version)
+        .signing_identity(identity, secret_key, cipher_suite);
+
+    if let Some(lifetime) = lifetime {
+        builder = builder
+            .key_package_lifetime(lifetime.not_after - lifetime.not_before)
+            .key_package_not_before(lifetime.not_before);
+    }
+
+    builder.build()
+}
+
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+#[cfg_attr(coverage_nightly, coverage(off))]
+pub async fn get_test_groups<C: CryptoProvider + Clone>(
+    version: ProtocolVersion,
+    cipher_suite: CipherSuite,
+    num_participants: usize,
+    commit_options: Option<CommitOptions>,
+    encrypt_controls: bool,
+    crypto: &C,
+) -> Vec<Group<impl MlsConfig>> {
+    // Create the group with Alice as the group initiator
+    let creator = generate_basic_client(
+        cipher_suite,
+        version,
+        0,
+        commit_options,
+        encrypt_controls,
+        crypto,
+        None,
+    )
+    .await;
+
+    let mut creator_group = creator.create_group(Default::default()).await.unwrap();
+
+    let mut receiver_clients = Vec::new();
+    let mut commit_builder = creator_group.commit_builder();
+
+    for i in 1..num_participants {
+        let client = generate_basic_client(
+            cipher_suite,
+            version,
+            i,
+            commit_options,
+            encrypt_controls,
+            crypto,
+            None,
+        )
+        .await;
+        let kp = client.generate_key_package_message().await.unwrap();
+
+        receiver_clients.push(client);
+        commit_builder = commit_builder.add_member(kp.clone()).unwrap();
+    }
+
+    let welcome = commit_builder.build().await.unwrap().welcome_messages;
+
+    creator_group.apply_pending_commit().await.unwrap();
+
+    let tree_data = creator_group.export_tree().into_owned();
+
+    let mut groups = vec![creator_group];
+
+    for client in &receiver_clients {
+        let (test_client, _info) = client
+            .join_group(Some(tree_data.clone()), &welcome[0])
+            .await
+            .unwrap();
+
+        groups.push(test_client);
+    }
+
+    groups
+}
+
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+#[cfg_attr(coverage_nightly, coverage(off))]
+pub async fn all_process_message<C: MlsConfig>(
+    groups: &mut [Group<C>],
+    message: &MlsMessage,
+    sender: usize,
+    is_commit: bool,
+) {
+    for group in groups {
+        if sender != group.current_member_index() as usize {
+            group
+                .process_incoming_message(message.clone())
+                .await
+                .unwrap();
+        } else if is_commit {
+            group.apply_pending_commit().await.unwrap();
+        }
+    }
+}
diff --git a/src/tree_kem/capabilities.rs b/src/tree_kem/capabilities.rs
new file mode 100644
index 0000000..6fc498d
--- /dev/null
+++ b/src/tree_kem/capabilities.rs
@@ -0,0 +1,5 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// Copyright by contributors to this project.
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+
+pub use mls_rs_core::group::Capabilities;
diff --git a/src/tree_kem/hpke_encryption.rs b/src/tree_kem/hpke_encryption.rs
new file mode 100644
index 0000000..77a598a
--- /dev/null
+++ b/src/tree_kem/hpke_encryption.rs
@@ -0,0 +1,172 @@
+// 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::{MlsEncode, MlsSize};
+use mls_rs_core::{
+    crypto::{CipherSuiteProvider, HpkeCiphertext, HpkePublicKey, HpkeSecretKey},
+    error::IntoAnyError,
+};
+use zeroize::Zeroizing;
+
+use crate::client::MlsError;
+
+#[derive(Clone, MlsSize, MlsEncode)]
+struct EncryptContext<'a> {
+    #[mls_codec(with = "mls_rs_codec::byte_vec")]
+    label: Vec<u8>,
+    #[mls_codec(with = "mls_rs_codec::byte_vec")]
+    context: &'a [u8],
+}
+
+impl Debug for EncryptContext<'_> {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.debug_struct("EncryptContext")
+            .field("label", &mls_rs_core::debug::pretty_bytes(&self.label))
+            .field("context", &mls_rs_core::debug::pretty_bytes(self.context))
+            .finish()
+    }
+}
+
+impl<'a> EncryptContext<'a> {
+    pub fn new(label: &str, context: &'a [u8]) -> Self {
+        Self {
+            label: [b"MLS 1.0 ", label.as_bytes()].concat(),
+            context,
+        }
+    }
+}
+
+#[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 HpkeEncryptable: Sized {
+    const ENCRYPT_LABEL: &'static str;
+
+    async fn encrypt<P: CipherSuiteProvider>(
+        &self,
+        cipher_suite_provider: &P,
+        public_key: &HpkePublicKey,
+        context: &[u8],
+    ) -> Result<HpkeCiphertext, MlsError> {
+        let context = EncryptContext::new(Self::ENCRYPT_LABEL, context)
+            .mls_encode_to_vec()
+            .map(Zeroizing::new)?;
+
+        let content = self.get_bytes().map(Zeroizing::new)?;
+
+        cipher_suite_provider
+            .hpke_seal(public_key, &context, None, &content)
+            .await
+            .map_err(|e| MlsError::CryptoProviderError(e.into_any_error()))
+    }
+
+    async fn decrypt<P: CipherSuiteProvider>(
+        cipher_suite_provider: &P,
+        secret_key: &HpkeSecretKey,
+        public_key: &HpkePublicKey,
+        context: &[u8],
+        ciphertext: &HpkeCiphertext,
+    ) -> Result<Self, MlsError> {
+        let context = EncryptContext::new(Self::ENCRYPT_LABEL, context).mls_encode_to_vec()?;
+
+        let plaintext = cipher_suite_provider
+            .hpke_open(ciphertext, secret_key, public_key, &context, None)
+            .await
+            .map_err(|e| MlsError::CryptoProviderError(e.into_any_error()))?;
+
+        Self::from_bytes(plaintext.to_vec())
+    }
+
+    fn from_bytes(bytes: Vec<u8>) -> Result<Self, MlsError>;
+    fn get_bytes(&self) -> Result<Vec<u8>, MlsError>;
+}
+
+#[cfg(test)]
+pub(crate) mod test_utils {
+    use alloc::{string::String, vec::Vec};
+    use mls_rs_codec::{MlsDecode, MlsEncode, MlsSize};
+    use mls_rs_core::crypto::{CipherSuiteProvider, HpkeCiphertext};
+
+    use crate::{client::MlsError, crypto::test_utils::try_test_cipher_suite_provider};
+
+    use super::HpkeEncryptable;
+
+    #[derive(Debug, serde::Serialize, serde::Deserialize)]
+    pub struct HpkeInteropTestCase {
+        #[serde(with = "hex::serde", rename = "priv")]
+        secret: Vec<u8>,
+        #[serde(with = "hex::serde", rename = "pub")]
+        public: Vec<u8>,
+        label: String,
+        #[serde(with = "hex::serde")]
+        context: Vec<u8>,
+        #[serde(with = "hex::serde")]
+        plaintext: Vec<u8>,
+        #[serde(with = "hex::serde")]
+        kem_output: Vec<u8>,
+        #[serde(with = "hex::serde")]
+        ciphertext: Vec<u8>,
+    }
+
+    #[derive(Debug, serde::Serialize, serde::Deserialize)]
+    pub struct InteropTestCase {
+        cipher_suite: u16,
+        encrypt_with_label: HpkeInteropTestCase,
+    }
+
+    #[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) {
+                test_case.encrypt_with_label.verify(&cs).await
+            }
+        }
+    }
+
+    #[derive(Clone, Debug, MlsSize, MlsEncode, MlsDecode)]
+    struct TestEncryptable(#[mls_codec(with = "mls_rs_codec::byte_vec")] Vec<u8>);
+
+    impl HpkeEncryptable for TestEncryptable {
+        const ENCRYPT_LABEL: &'static str = "EncryptWithLabel";
+
+        fn from_bytes(bytes: Vec<u8>) -> Result<Self, MlsError> {
+            Ok(Self(bytes))
+        }
+
+        #[cfg_attr(coverage_nightly, coverage(off))]
+        fn get_bytes(&self) -> Result<Vec<u8>, MlsError> {
+            Ok(self.0.clone())
+        }
+    }
+
+    impl HpkeInteropTestCase {
+        #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+        pub async fn verify<P: CipherSuiteProvider>(&self, cs: &P) {
+            let secret = self.secret.clone().into();
+            let public = self.public.clone().into();
+
+            let ciphertext = HpkeCiphertext {
+                kem_output: self.kem_output.clone(),
+                ciphertext: self.ciphertext.clone(),
+            };
+
+            let computed_plaintext =
+                TestEncryptable::decrypt(cs, &secret, &public, &self.context, &ciphertext)
+                    .await
+                    .unwrap();
+
+            assert_eq!(&computed_plaintext.0, &self.plaintext)
+        }
+    }
+}
diff --git a/src/tree_kem/interop_test_vectors.rs b/src/tree_kem/interop_test_vectors.rs
new file mode 100644
index 0000000..50e0077
--- /dev/null
+++ b/src/tree_kem/interop_test_vectors.rs
@@ -0,0 +1,199 @@
+// 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};
+
+use itertools::Itertools;
+
+use crate::{
+    crypto::test_utils::try_test_cipher_suite_provider, identity::basic::BasicIdentityProvider,
+};
+
+use super::{
+    node::NodeVec, test_utils::TreeWithSigners, tree_validator::TreeValidator, TreeKemPublic,
+};
+
+#[derive(serde::Serialize, serde::Deserialize, Debug, Default, Clone)]
+struct ValidationTestCase {
+    pub cipher_suite: u16,
+
+    #[serde(with = "hex::serde")]
+    pub tree: Vec<u8>,
+    #[serde(with = "hex::serde")]
+    pub group_id: Vec<u8>,
+    pub tree_hashes: Vec<TreeHash>,
+    pub resolutions: Vec<Vec<u32>>,
+}
+
+#[derive(serde::Serialize, serde::Deserialize, Debug, Default, Clone)]
+struct TreeHash(#[serde(with = "hex::serde")] pub Vec<u8>);
+
+impl From<crate::tree_kem::tree_hash::TreeHash> for TreeHash {
+    #[cfg_attr(coverage_nightly, coverage(off))]
+    fn from(value: crate::tree_kem::tree_hash::TreeHash) -> Self {
+        TreeHash(value.to_vec())
+    }
+}
+
+impl ValidationTestCase {
+    #[cfg_attr(coverage_nightly, coverage(off))]
+    fn new<P: CipherSuiteProvider>(tree: TreeKemPublic, group_id: &[u8], cs: &P) -> Self {
+        let tree_size = tree.total_leaf_count() * 2 - 1;
+
+        assert!(
+            tree.tree_hashes.current.len() == tree_size as usize,
+            "hashes not initialized"
+        );
+
+        let resolutions = (0..tree_size)
+            .map(
+                #[cfg_attr(coverage_nightly, coverage(off))]
+                |i| tree.nodes.get_resolution_index(i).unwrap(),
+            )
+            .collect();
+
+        Self {
+            cipher_suite: cs.cipher_suite().into(),
+            tree: tree.nodes.mls_encode_to_vec().unwrap(),
+            tree_hashes: tree
+                .tree_hashes
+                .current
+                .into_iter()
+                .map(TreeHash::from)
+                .collect(),
+            group_id: group_id.to_vec(),
+            resolutions,
+        }
+    }
+}
+
+#[cfg(feature = "rfc_compliant")]
+#[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+#[cfg_attr(coverage_nightly, coverage(off))]
+async fn validation() {
+    use crate::group::test_utils::get_test_group_context;
+
+    #[cfg(mls_build_async)]
+    let test_cases: Vec<ValidationTestCase> = load_test_case_json!(
+        interop_tree_validation,
+        generate_validation_test_vector().await
+    );
+
+    #[cfg(not(mls_build_async))]
+    let test_cases: Vec<ValidationTestCase> =
+        load_test_case_json!(interop_tree_validation, generate_validation_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 mut tree = TreeKemPublic::import_node_data(
+            NodeVec::mls_decode(&mut &*test_case.tree).unwrap(),
+            &BasicIdentityProvider,
+            &Default::default(),
+        )
+        .await
+        .unwrap();
+
+        let tree_hash = tree.tree_hash(&cs).await.unwrap();
+
+        tree.tree_hashes
+            .current
+            .iter()
+            .zip_eq(test_case.tree_hashes.iter())
+            .for_each(|(l, r)| assert_eq!(**l, *r.0));
+
+        test_case
+            .resolutions
+            .iter()
+            .enumerate()
+            .for_each(|(i, res)| {
+                assert_eq!(&tree.nodes.get_resolution_index(i as u32).unwrap(), res)
+            });
+
+        let mut context = get_test_group_context(1, test_case.cipher_suite.into()).await;
+        context.tree_hash = tree_hash;
+        context.group_id = test_case.group_id;
+
+        TreeValidator::new(&cs, &context, &BasicIdentityProvider)
+            .validate(&mut tree)
+            .await
+            .unwrap();
+    }
+}
+
+#[cfg(feature = "rfc_compliant")]
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+#[cfg_attr(coverage_nightly, coverage(off))]
+async fn generate_validation_test_vector() -> Vec<ValidationTestCase> {
+    let mut test_cases = vec![];
+
+    for cs in CipherSuite::all() {
+        let Some(cs) = try_test_cipher_suite_provider(*cs) else {
+            continue;
+        };
+
+        let mut trees = vec![];
+
+        // Generate trees with increasing complexity. Start: full complete trees
+        for n_leaves in [2, 4, 8, 32] {
+            trees.push(TreeWithSigners::make_full_tree(n_leaves, &cs).await);
+        }
+
+        // Internal blanks, no skipping : 8 leaves, 0 commits removing 2, 3 and adding new member
+        let mut tree = TreeWithSigners::make_full_tree(8, &cs).await;
+        tree.remove_member(2);
+        tree.remove_member(3);
+        tree.add_member("Bob", &cs).await;
+        tree.update_committer_path(0, &cs).await;
+        trees.push(tree);
+
+        // Blanks at the end, no skipping
+        for n_leaves in [3, 5, 7, 33] {
+            trees.push(TreeWithSigners::make_full_tree(n_leaves, &cs).await);
+        }
+
+        // Internal blanks, with skipping : 8 leaves, 0 commits removing 1, 2, 3
+        let mut tree = TreeWithSigners::make_full_tree(8, &cs).await;
+        [1, 2, 3].into_iter().for_each(
+            #[cfg_attr(coverage_nightly, coverage(off))]
+            |i| tree.remove_member(i),
+        );
+        tree.update_committer_path(0, &cs).await;
+        trees.push(tree);
+
+        // Blanks at the end, with skipping
+        for n_leaves in [6, 34] {
+            trees.push(TreeWithSigners::make_full_tree(n_leaves, &cs).await);
+        }
+
+        // Unmerged leaves, no skipping : 7 leaves; 0 commits adding a member
+        let mut tree = TreeWithSigners::make_full_tree(7, &cs).await;
+        tree.add_member("Bob", &cs).await;
+        tree.update_committer_path(0, &cs).await;
+        trees.push(tree);
+
+        // Unmerged leaves, with skipping : figure 20 in the RFC
+        let mut tree = TreeWithSigners::make_full_tree(7, &cs).await;
+        tree.remove_member(5);
+        tree.update_committer_path(0, &cs).await;
+        tree.update_committer_path(4, &cs).await;
+        tree.add_member("Bob", &cs).await;
+        tree.tree.tree_hashes.current = vec![];
+        tree.tree.tree_hash(&cs).await.unwrap();
+        trees.push(tree);
+
+        // Generate tests
+        trees.into_iter().for_each(
+            #[cfg_attr(coverage_nightly, coverage(off))]
+            |tree| test_cases.push(ValidationTestCase::new(tree.tree, &tree.group_id, &cs)),
+        );
+    }
+
+    test_cases
+}
diff --git a/src/tree_kem/kem.rs b/src/tree_kem/kem.rs
new file mode 100644
index 0000000..cedeb0e
--- /dev/null
+++ b/src/tree_kem/kem.rs
@@ -0,0 +1,699 @@
+// 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, SignatureSecretKey};
+use crate::group::GroupContext;
+use crate::identity::SigningIdentity;
+use crate::iter::wrap_iter;
+use crate::tree_kem::math as tree_math;
+use alloc::vec;
+use alloc::vec::Vec;
+use itertools::Itertools;
+use mls_rs_codec::MlsEncode;
+use tree_math::{CopathNode, TreeIndex};
+
+#[cfg(all(not(mls_build_async), feature = "rayon"))]
+use {crate::iter::ParallelIteratorExt, rayon::prelude::*};
+
+#[cfg(mls_build_async)]
+use futures::{StreamExt, TryStreamExt};
+
+#[cfg(feature = "std")]
+use std::collections::HashSet;
+
+use super::hpke_encryption::HpkeEncryptable;
+use super::leaf_node::ConfigProperties;
+use super::node::NodeTypeResolver;
+use super::{
+    node::{LeafIndex, NodeIndex},
+    path_secret::{PathSecret, PathSecretGenerator},
+    TreeKemPrivate, TreeKemPublic, UpdatePath, UpdatePathNode, ValidatedUpdatePath,
+};
+
+#[cfg(test)]
+use crate::{group::CommitModifiers, signer::Signable};
+
+pub struct TreeKem<'a> {
+    tree_kem_public: &'a mut TreeKemPublic,
+    private_key: &'a mut TreeKemPrivate,
+}
+
+pub struct EncapGeneration {
+    pub update_path: UpdatePath,
+    pub path_secrets: Vec<Option<PathSecret>>,
+    pub commit_secret: PathSecret,
+}
+
+impl<'a> TreeKem<'a> {
+    pub fn new(
+        tree_kem_public: &'a mut TreeKemPublic,
+        private_key: &'a mut TreeKemPrivate,
+    ) -> Self {
+        TreeKem {
+            tree_kem_public,
+            private_key,
+        }
+    }
+
+    #[allow(clippy::too_many_arguments)]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn encap<P>(
+        self,
+        context: &mut GroupContext,
+        excluding: &[LeafIndex],
+        signer: &SignatureSecretKey,
+        update_leaf_properties: ConfigProperties,
+        signing_identity: Option<SigningIdentity>,
+        cipher_suite_provider: &P,
+        #[cfg(test)] commit_modifiers: &CommitModifiers,
+    ) -> Result<EncapGeneration, MlsError>
+    where
+        P: CipherSuiteProvider + Send + Sync,
+    {
+        let self_index = self.private_key.self_index;
+        let path = self.tree_kem_public.nodes.direct_copath(self_index);
+        let filtered = self.tree_kem_public.nodes.filtered(self_index)?;
+
+        self.private_key.secret_keys.resize(path.len() + 1, None);
+
+        let mut secret_generator = PathSecretGenerator::new(cipher_suite_provider);
+        let mut path_secrets = vec![];
+
+        for (i, (node, f)) in path.iter().zip(&filtered).enumerate() {
+            if !f {
+                let secret = secret_generator.next_secret().await?;
+
+                let (secret_key, public_key) =
+                    secret.to_hpke_key_pair(cipher_suite_provider).await?;
+
+                self.private_key.secret_keys[i + 1] = Some(secret_key);
+                self.tree_kem_public.update_node(public_key, node.path)?;
+                path_secrets.push(Some(secret));
+            } else {
+                self.private_key.secret_keys[i + 1] = None;
+                path_secrets.push(None);
+            }
+        }
+
+        #[cfg(test)]
+        (commit_modifiers.modify_tree)(self.tree_kem_public);
+
+        self.tree_kem_public
+            .update_parent_hashes(self_index, false, cipher_suite_provider)
+            .await?;
+
+        let update_path_leaf = {
+            let own_leaf = self.tree_kem_public.nodes.borrow_as_leaf_mut(self_index)?;
+
+            self.private_key.secret_keys[0] = Some(
+                own_leaf
+                    .commit(
+                        cipher_suite_provider,
+                        &context.group_id,
+                        *self_index,
+                        update_leaf_properties,
+                        signing_identity,
+                        signer,
+                    )
+                    .await?,
+            );
+
+            #[cfg(test)]
+            if let Some(signer) = (commit_modifiers.modify_leaf)(own_leaf, signer) {
+                let context = &(context.group_id.as_slice(), *self_index).into();
+
+                own_leaf
+                    .sign(cipher_suite_provider, &signer, context)
+                    .await
+                    .unwrap();
+            }
+
+            own_leaf.clone()
+        };
+
+        // Tree modifications are all done so we can update the tree hash and encrypt with the new context
+        self.tree_kem_public
+            .update_hashes(&[self_index], cipher_suite_provider)
+            .await?;
+
+        context.tree_hash = self
+            .tree_kem_public
+            .tree_hash(cipher_suite_provider)
+            .await?;
+
+        let context_bytes = context.mls_encode_to_vec()?;
+
+        let node_updates = self
+            .encrypt_path_secrets(
+                path,
+                &path_secrets,
+                &context_bytes,
+                cipher_suite_provider,
+                excluding,
+            )
+            .await?;
+
+        #[cfg(test)]
+        let node_updates = (commit_modifiers.modify_path)(node_updates);
+
+        // Create an update path with the new node and parent node updates
+        let update_path = UpdatePath {
+            leaf_node: update_path_leaf,
+            nodes: node_updates,
+        };
+
+        Ok(EncapGeneration {
+            update_path,
+            path_secrets,
+            commit_secret: secret_generator.next_secret().await?,
+        })
+    }
+
+    #[cfg(any(mls_build_async, not(feature = "rayon")))]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn encrypt_path_secrets<P: CipherSuiteProvider>(
+        &self,
+        path: Vec<CopathNode<NodeIndex>>,
+        path_secrets: &[Option<PathSecret>],
+        context_bytes: &[u8],
+        cipher_suite: &P,
+        excluding: &[LeafIndex],
+    ) -> Result<Vec<UpdatePathNode>, MlsError> {
+        let excluding = excluding.iter().copied().map(NodeIndex::from);
+
+        #[cfg(feature = "std")]
+        let excluding = excluding.collect::<HashSet<NodeIndex>>();
+        #[cfg(not(feature = "std"))]
+        let excluding = excluding.collect::<Vec<NodeIndex>>();
+
+        let mut node_updates = Vec::new();
+
+        for (index, path_secret) in path.into_iter().zip(path_secrets.iter()) {
+            if let Some(path_secret) = path_secret {
+                node_updates.push(
+                    self.encrypt_copath_node_resolution(
+                        cipher_suite,
+                        path_secret,
+                        index.copath,
+                        context_bytes,
+                        &excluding,
+                    )
+                    .await?,
+                );
+            }
+        }
+
+        Ok(node_updates)
+    }
+
+    #[cfg(all(not(mls_build_async), feature = "rayon"))]
+    fn encrypt_path_secrets<P: CipherSuiteProvider>(
+        &self,
+        path: Vec<CopathNode<NodeIndex>>,
+        path_secrets: &[Option<PathSecret>],
+        context_bytes: &[u8],
+        cipher_suite: &P,
+        excluding: &[LeafIndex],
+    ) -> Result<Vec<UpdatePathNode>, MlsError> {
+        let excluding = excluding.iter().copied().map(NodeIndex::from);
+
+        #[cfg(feature = "std")]
+        let excluding = excluding.collect::<HashSet<NodeIndex>>();
+        #[cfg(not(feature = "std"))]
+        let excluding = excluding.collect::<Vec<NodeIndex>>();
+
+        path.into_par_iter()
+            .zip(path_secrets.par_iter())
+            .filter_map(|(node, path_secret)| {
+                path_secret.as_ref().map(|path_secret| {
+                    self.encrypt_copath_node_resolution(
+                        cipher_suite,
+                        path_secret,
+                        node.copath,
+                        context_bytes,
+                        &excluding,
+                    )
+                })
+            })
+            .collect()
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn decap<CP>(
+        self,
+        sender_index: LeafIndex,
+        update_path: &ValidatedUpdatePath,
+        added_leaves: &[LeafIndex],
+        context_bytes: &[u8],
+        cipher_suite_provider: &CP,
+    ) -> Result<PathSecret, MlsError>
+    where
+        CP: CipherSuiteProvider,
+    {
+        let self_index = self.private_key.self_index;
+
+        let lca_index =
+            tree_math::leaf_lca_level(self_index.into(), sender_index.into()) as usize - 2;
+
+        let mut path = self.tree_kem_public.nodes.direct_copath(self_index);
+        let leaf = CopathNode::new(self_index.into(), 0);
+        path.insert(0, leaf);
+        let resolved_pos = self.find_resolved_pos(&path, lca_index)?;
+
+        let ct_pos =
+            self.find_ciphertext_pos(path[lca_index].path, path[resolved_pos].path, added_leaves)?;
+
+        let lca_node = update_path.nodes[lca_index]
+            .as_ref()
+            .ok_or(MlsError::LcaNotFoundInDirectPath)?;
+
+        let ct = lca_node
+            .encrypted_path_secret
+            .get(ct_pos)
+            .ok_or(MlsError::LcaNotFoundInDirectPath)?;
+
+        let secret = self.private_key.secret_keys[resolved_pos]
+            .as_ref()
+            .ok_or(MlsError::UpdateErrorNoSecretKey)?;
+
+        let public = self
+            .tree_kem_public
+            .nodes
+            .borrow_node(path[resolved_pos].path)?
+            .as_ref()
+            .ok_or(MlsError::UpdateErrorNoSecretKey)?
+            .public_key();
+
+        let lca_path_secret =
+            PathSecret::decrypt(cipher_suite_provider, secret, public, context_bytes, ct).await?;
+
+        // Derive the rest of the secrets for the tree and assign to the proper nodes
+        let mut node_secret_gen =
+            PathSecretGenerator::starting_with(cipher_suite_provider, lca_path_secret);
+
+        // Update secrets based on the decrypted path secret in the update
+        self.private_key.secret_keys.resize(path.len() + 1, None);
+
+        for (i, update) in update_path.nodes.iter().enumerate().skip(lca_index) {
+            if let Some(update) = update {
+                let secret = node_secret_gen.next_secret().await?;
+
+                // Verify the private key we calculated properly matches the public key we inserted into the tree. This guarantees
+                // that we will be able to decrypt later.
+                let (hpke_private, hpke_public) =
+                    secret.to_hpke_key_pair(cipher_suite_provider).await?;
+
+                if hpke_public != update.public_key {
+                    return Err(MlsError::PubKeyMismatch);
+                }
+
+                self.private_key.secret_keys[i + 1] = Some(hpke_private);
+            } else {
+                self.private_key.secret_keys[i + 1] = None;
+            }
+        }
+
+        node_secret_gen.next_secret().await
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn encrypt_copath_node_resolution<P: CipherSuiteProvider>(
+        &self,
+        cipher_suite_provider: &P,
+        path_secret: &PathSecret,
+        copath_index: NodeIndex,
+        context: &[u8],
+        #[cfg(feature = "std")] excluding: &HashSet<NodeIndex>,
+        #[cfg(not(feature = "std"))] excluding: &[NodeIndex],
+    ) -> Result<UpdatePathNode, MlsError> {
+        let reso = self
+            .tree_kem_public
+            .nodes
+            .get_resolution_index(copath_index)?;
+
+        let make_ctxt = |idx| async move {
+            let node = self
+                .tree_kem_public
+                .nodes
+                .borrow_node(idx)?
+                .as_non_empty()?;
+
+            path_secret
+                .encrypt(cipher_suite_provider, node.public_key(), context)
+                .await
+        };
+
+        let ctxts = wrap_iter(reso).filter(|&idx| async move { !excluding.contains(&idx) });
+
+        #[cfg(not(mls_build_async))]
+        let ctxts = ctxts.map(make_ctxt);
+
+        #[cfg(mls_build_async)]
+        let ctxts = ctxts.then(make_ctxt);
+
+        let ctxts = ctxts.try_collect().await?;
+
+        let path_index = copath_index
+            .parent_sibling(&self.tree_kem_public.total_leaf_count())
+            .ok_or(MlsError::ExpectedNode)?
+            .parent;
+
+        Ok(UpdatePathNode {
+            public_key: self
+                .tree_kem_public
+                .nodes
+                .borrow_as_parent(path_index)?
+                .public_key
+                .clone(),
+            encrypted_path_secret: ctxts,
+        })
+    }
+
+    #[inline]
+    fn find_resolved_pos(
+        &self,
+        path: &[CopathNode<NodeIndex>],
+        mut lca_index: usize,
+    ) -> Result<usize, MlsError> {
+        while self.tree_kem_public.nodes.is_blank(path[lca_index].path)? {
+            lca_index -= 1;
+        }
+
+        // If we don't have the key, we should be an unmerged leaf at the resolved node. (If
+        // we're not, an error will be thrown later.)
+        if self.private_key.secret_keys[lca_index].is_none() {
+            lca_index = 0;
+        }
+
+        Ok(lca_index)
+    }
+
+    #[inline]
+    fn find_ciphertext_pos(
+        &self,
+        lca: NodeIndex,
+        resolved: NodeIndex,
+        excluding: &[LeafIndex],
+    ) -> Result<usize, MlsError> {
+        let reso = self.tree_kem_public.nodes.get_resolution_index(lca)?;
+
+        let (ct_pos, _) = reso
+            .iter()
+            .filter(|idx| **idx % 2 == 1 || !excluding.contains(&LeafIndex(**idx / 2)))
+            .find_position(|idx| idx == &&resolved)
+            .ok_or(MlsError::UpdateErrorNoSecretKey)?;
+
+        Ok(ct_pos)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::{tree_math, TreeKem};
+    use crate::{
+        cipher_suite::CipherSuite,
+        client::test_utils::TEST_CIPHER_SUITE,
+        crypto::test_utils::{test_cipher_suite_provider, TestCryptoProvider},
+        extension::test_utils::TestExtension,
+        group::test_utils::{get_test_group_context, random_bytes},
+        identity::basic::BasicIdentityProvider,
+        tree_kem::{
+            leaf_node::{
+                test_utils::{get_basic_test_node_sig_key, get_test_capabilities},
+                ConfigProperties,
+            },
+            node::LeafIndex,
+            Capabilities, TreeKemPrivate, TreeKemPublic, UpdatePath, ValidatedUpdatePath,
+        },
+        ExtensionList,
+    };
+    use alloc::{format, vec, vec::Vec};
+    use mls_rs_codec::MlsEncode;
+    use mls_rs_core::crypto::CipherSuiteProvider;
+    use tree_math::TreeIndex;
+
+    // Verify that the tree is in the correct state after generating an update path
+    fn verify_tree_update_path(
+        tree: &TreeKemPublic,
+        update_path: &UpdatePath,
+        index: LeafIndex,
+        capabilities: Option<Capabilities>,
+        extensions: Option<ExtensionList>,
+    ) {
+        // Make sure the update path is based on the direct path of the sender
+        let direct_path = tree.nodes.direct_copath(index);
+
+        for (i, n) in direct_path.iter().enumerate() {
+            assert_eq!(
+                *tree
+                    .nodes
+                    .borrow_node(n.path)
+                    .unwrap()
+                    .as_ref()
+                    .unwrap()
+                    .public_key(),
+                update_path.nodes[i].public_key
+            );
+        }
+
+        // Verify that the leaf from the update path has been installed
+        assert_eq!(
+            tree.nodes.borrow_as_leaf(index).unwrap(),
+            &update_path.leaf_node
+        );
+
+        // Verify that updated capabilities were installed
+        if let Some(capabilities) = capabilities {
+            assert_eq!(update_path.leaf_node.capabilities, capabilities);
+        }
+
+        // Verify that update extensions were installed
+        if let Some(extensions) = extensions {
+            assert_eq!(update_path.leaf_node.extensions, extensions);
+        }
+
+        // Verify that we have a public keys up to the root
+        let root = tree.total_leaf_count().root();
+        assert!(tree.nodes.borrow_node(root).unwrap().is_some());
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn verify_tree_private_path(
+        cipher_suite: &CipherSuite,
+        public_tree: &TreeKemPublic,
+        private_tree: &TreeKemPrivate,
+        index: LeafIndex,
+    ) {
+        let provider = test_cipher_suite_provider(*cipher_suite);
+
+        assert_eq!(private_tree.self_index, index);
+
+        // Make sure we have private values along the direct path, and the public keys match
+        let path_iter = public_tree
+            .nodes
+            .direct_copath(index)
+            .into_iter()
+            .enumerate();
+
+        for (i, n) in path_iter {
+            let secret_key = private_tree.secret_keys[i + 1].as_ref().unwrap();
+
+            let public_key = public_tree
+                .nodes
+                .borrow_node(n.path)
+                .unwrap()
+                .as_ref()
+                .unwrap()
+                .public_key();
+
+            let test_data = random_bytes(32);
+
+            let sealed = provider
+                .hpke_seal(public_key, &[], None, &test_data)
+                .await
+                .unwrap();
+
+            let opened = provider
+                .hpke_open(&sealed, secret_key, public_key, &[], None)
+                .await
+                .unwrap();
+
+            assert_eq!(test_data, opened);
+        }
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn encap_decap(
+        cipher_suite: CipherSuite,
+        size: usize,
+        capabilities: Option<Capabilities>,
+        extensions: Option<ExtensionList>,
+    ) {
+        let cipher_suite_provider = test_cipher_suite_provider(cipher_suite);
+
+        // Generate signing keys and key package generations, and private keys for multiple
+        // participants in order to set up state
+
+        let mut leaf_nodes = Vec::new();
+        let mut private_keys = Vec::new();
+
+        for index in 1..size {
+            let (leaf_node, hpke_secret, _) =
+                get_basic_test_node_sig_key(cipher_suite, &format!("{index}")).await;
+
+            let private_key = TreeKemPrivate::new_self_leaf(LeafIndex(index as u32), hpke_secret);
+
+            leaf_nodes.push(leaf_node);
+            private_keys.push(private_key);
+        }
+
+        let (encap_node, encap_hpke_secret, encap_signer) =
+            get_basic_test_node_sig_key(cipher_suite, "encap").await;
+
+        // Build a test tree we can clone for all leaf nodes
+        let (mut test_tree, mut encap_private_key) = TreeKemPublic::derive(
+            encap_node,
+            encap_hpke_secret,
+            &BasicIdentityProvider,
+            &Default::default(),
+        )
+        .await
+        .unwrap();
+
+        test_tree
+            .add_leaves(leaf_nodes, &BasicIdentityProvider, &cipher_suite_provider)
+            .await
+            .unwrap();
+
+        // Clone the tree for the first leaf, generate a new key package for that leaf
+        let mut encap_tree = test_tree.clone();
+
+        let update_leaf_properties = ConfigProperties {
+            capabilities: capabilities.clone().unwrap_or_else(get_test_capabilities),
+            extensions: extensions.clone().unwrap_or_default(),
+        };
+
+        // Perform the encap function
+        let encap_gen = TreeKem::new(&mut encap_tree, &mut encap_private_key)
+            .encap(
+                &mut get_test_group_context(42, cipher_suite).await,
+                &[],
+                &encap_signer,
+                update_leaf_properties,
+                None,
+                &cipher_suite_provider,
+                #[cfg(test)]
+                &Default::default(),
+            )
+            .await
+            .unwrap();
+
+        // Verify that the state of the tree matches the produced update path
+        verify_tree_update_path(
+            &encap_tree,
+            &encap_gen.update_path,
+            LeafIndex(0),
+            capabilities,
+            extensions,
+        );
+
+        // Verify that the private key matches the data in the public key
+        verify_tree_private_path(&cipher_suite, &encap_tree, &encap_private_key, LeafIndex(0))
+            .await;
+
+        let filtered = test_tree.nodes.filtered(LeafIndex(0)).unwrap();
+        let mut unfiltered_nodes = vec![None; filtered.len()];
+        filtered
+            .into_iter()
+            .enumerate()
+            .filter(|(_, f)| !*f)
+            .zip(encap_gen.update_path.nodes.iter())
+            .for_each(|((i, _), node)| {
+                unfiltered_nodes[i] = Some(node.clone());
+            });
+
+        // Apply the update path to the rest of the leaf nodes using the decap function
+        let validated_update_path = ValidatedUpdatePath {
+            leaf_node: encap_gen.update_path.leaf_node,
+            nodes: unfiltered_nodes,
+        };
+
+        encap_tree
+            .update_hashes(&[LeafIndex(0)], &cipher_suite_provider)
+            .await
+            .unwrap();
+
+        let mut receiver_trees: Vec<TreeKemPublic> = (1..size).map(|_| test_tree.clone()).collect();
+
+        for (i, tree) in receiver_trees.iter_mut().enumerate() {
+            tree.apply_update_path(
+                LeafIndex(0),
+                &validated_update_path,
+                &Default::default(),
+                BasicIdentityProvider,
+                &cipher_suite_provider,
+            )
+            .await
+            .unwrap();
+
+            let mut context = get_test_group_context(42, cipher_suite).await;
+            context.tree_hash = tree.tree_hash(&cipher_suite_provider).await.unwrap();
+
+            TreeKem::new(tree, &mut private_keys[i])
+                .decap(
+                    LeafIndex(0),
+                    &validated_update_path,
+                    &[],
+                    &context.mls_encode_to_vec().unwrap(),
+                    &cipher_suite_provider,
+                )
+                .await
+                .unwrap();
+
+            tree.update_hashes(&[LeafIndex(0)], &cipher_suite_provider)
+                .await
+                .unwrap();
+
+            assert_eq!(tree, &encap_tree);
+        }
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_encap_decap() {
+        for cipher_suite in TestCryptoProvider::all_supported_cipher_suites() {
+            encap_decap(cipher_suite, 10, None, None).await;
+        }
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_encap_capabilities() {
+        let cipher_suite = TEST_CIPHER_SUITE;
+        let mut capabilities = get_test_capabilities();
+        capabilities.extensions.push(42.into());
+
+        encap_decap(cipher_suite, 10, Some(capabilities.clone()), None).await;
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_encap_extensions() {
+        let cipher_suite = TEST_CIPHER_SUITE;
+        let mut extensions = ExtensionList::default();
+        extensions.set_from(TestExtension { foo: 10 }).unwrap();
+
+        encap_decap(cipher_suite, 10, None, Some(extensions)).await;
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_encap_capabilities_extensions() {
+        let cipher_suite = TEST_CIPHER_SUITE;
+        let mut capabilities = get_test_capabilities();
+        capabilities.extensions.push(42.into());
+
+        let mut extensions = ExtensionList::default();
+        extensions.set_from(TestExtension { foo: 10 }).unwrap();
+
+        encap_decap(cipher_suite, 10, Some(capabilities), Some(extensions)).await;
+    }
+}
diff --git a/src/tree_kem/leaf_node.rs b/src/tree_kem/leaf_node.rs
new file mode 100644
index 0000000..c59ed78
--- /dev/null
+++ b/src/tree_kem/leaf_node.rs
@@ -0,0 +1,688 @@
+// 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::{parent_hash::ParentHash, Capabilities, Lifetime};
+use crate::client::MlsError;
+use crate::crypto::{CipherSuiteProvider, HpkePublicKey, HpkeSecretKey, SignatureSecretKey};
+use crate::{identity::SigningIdentity, signer::Signable, ExtensionList};
+use alloc::vec::Vec;
+use core::fmt::{self, Debug};
+use mls_rs_codec::{MlsDecode, MlsEncode, MlsSize};
+use mls_rs_core::error::IntoAnyError;
+
+#[derive(Debug, Clone, MlsSize, MlsEncode, MlsDecode, PartialEq, Eq)]
+#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[repr(u8)]
+pub enum LeafNodeSource {
+    KeyPackage(Lifetime) = 1u8,
+    Update = 2u8,
+    Commit(ParentHash) = 3u8,
+}
+
+#[derive(Clone, MlsSize, MlsEncode, MlsDecode, PartialEq)]
+#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[non_exhaustive]
+pub struct LeafNode {
+    pub public_key: HpkePublicKey,
+    pub signing_identity: SigningIdentity,
+    pub capabilities: Capabilities,
+    pub leaf_node_source: LeafNodeSource,
+    pub extensions: ExtensionList,
+    #[mls_codec(with = "mls_rs_codec::byte_vec")]
+    #[cfg_attr(feature = "serde", serde(with = "mls_rs_core::vec_serde"))]
+    pub signature: Vec<u8>,
+}
+
+impl Debug for LeafNode {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.debug_struct("LeafNode")
+            .field("public_key", &self.public_key)
+            .field("signing_identity", &self.signing_identity)
+            .field("capabilities", &self.capabilities)
+            .field("leaf_node_source", &self.leaf_node_source)
+            .field("extensions", &self.extensions)
+            .field(
+                "signature",
+                &mls_rs_core::debug::pretty_bytes(&self.signature),
+            )
+            .finish()
+    }
+}
+
+#[derive(Clone, Debug)]
+pub struct ConfigProperties {
+    pub capabilities: Capabilities,
+    pub extensions: ExtensionList,
+}
+
+impl LeafNode {
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn generate<CSP>(
+        cipher_suite_provider: &CSP,
+        properties: ConfigProperties,
+        signing_identity: SigningIdentity,
+        signer: &SignatureSecretKey,
+        lifetime: Lifetime,
+    ) -> Result<(Self, HpkeSecretKey), MlsError>
+    where
+        CSP: CipherSuiteProvider,
+    {
+        let (secret_key, public_key) = cipher_suite_provider
+            .kem_generate()
+            .await
+            .map_err(|e| MlsError::CryptoProviderError(e.into_any_error()))?;
+
+        let mut leaf_node = LeafNode {
+            public_key,
+            signing_identity,
+            capabilities: properties.capabilities,
+            leaf_node_source: LeafNodeSource::KeyPackage(lifetime),
+            extensions: properties.extensions,
+            signature: Default::default(),
+        };
+
+        leaf_node.grease(cipher_suite_provider)?;
+
+        leaf_node
+            .sign(
+                cipher_suite_provider,
+                signer,
+                &LeafNodeSigningContext::default(),
+            )
+            .await?;
+
+        Ok((leaf_node, secret_key))
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn update<P: CipherSuiteProvider>(
+        &mut self,
+        cipher_suite_provider: &P,
+        group_id: &[u8],
+        leaf_index: u32,
+        new_properties: ConfigProperties,
+        signing_identity: Option<SigningIdentity>,
+        signer: &SignatureSecretKey,
+    ) -> Result<HpkeSecretKey, MlsError> {
+        let (secret, public) = cipher_suite_provider
+            .kem_generate()
+            .await
+            .map_err(|e| MlsError::CryptoProviderError(e.into_any_error()))?;
+
+        self.public_key = public;
+        self.capabilities = new_properties.capabilities;
+        self.extensions = new_properties.extensions;
+        self.leaf_node_source = LeafNodeSource::Update;
+
+        self.grease(cipher_suite_provider)?;
+
+        if let Some(signing_identity) = signing_identity {
+            self.signing_identity = signing_identity;
+        }
+
+        self.sign(
+            cipher_suite_provider,
+            signer,
+            &(group_id, leaf_index).into(),
+        )
+        .await?;
+
+        Ok(secret)
+    }
+
+    #[allow(clippy::too_many_arguments)]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn commit<P: CipherSuiteProvider>(
+        &mut self,
+        cipher_suite_provider: &P,
+        group_id: &[u8],
+        leaf_index: u32,
+        new_properties: ConfigProperties,
+        new_signing_identity: Option<SigningIdentity>,
+        signer: &SignatureSecretKey,
+    ) -> Result<HpkeSecretKey, MlsError> {
+        let (secret, public) = cipher_suite_provider
+            .kem_generate()
+            .await
+            .map_err(|e| MlsError::CryptoProviderError(e.into_any_error()))?;
+
+        self.public_key = public;
+        self.capabilities = new_properties.capabilities;
+        self.extensions = new_properties.extensions;
+
+        if let Some(new_signing_identity) = new_signing_identity {
+            self.signing_identity = new_signing_identity;
+        }
+
+        self.sign(
+            cipher_suite_provider,
+            signer,
+            &(group_id, leaf_index).into(),
+        )
+        .await?;
+
+        Ok(secret)
+    }
+}
+
+#[derive(Debug)]
+struct LeafNodeTBS<'a> {
+    public_key: &'a HpkePublicKey,
+    signing_identity: &'a SigningIdentity,
+    capabilities: &'a Capabilities,
+    leaf_node_source: &'a LeafNodeSource,
+    extensions: &'a ExtensionList,
+    group_id: Option<&'a [u8]>,
+    leaf_index: Option<u32>,
+}
+
+impl<'a> MlsSize for LeafNodeTBS<'a> {
+    fn mls_encoded_len(&self) -> usize {
+        self.public_key.mls_encoded_len()
+            + self.signing_identity.mls_encoded_len()
+            + self.capabilities.mls_encoded_len()
+            + self.leaf_node_source.mls_encoded_len()
+            + self.extensions.mls_encoded_len()
+            + self
+                .group_id
+                .as_ref()
+                .map_or(0, mls_rs_codec::byte_vec::mls_encoded_len)
+            + self.leaf_index.map_or(0, |i| i.mls_encoded_len())
+    }
+}
+
+impl<'a> MlsEncode for LeafNodeTBS<'a> {
+    fn mls_encode(&self, writer: &mut Vec<u8>) -> Result<(), mls_rs_codec::Error> {
+        self.public_key.mls_encode(writer)?;
+        self.signing_identity.mls_encode(writer)?;
+        self.capabilities.mls_encode(writer)?;
+        self.leaf_node_source.mls_encode(writer)?;
+        self.extensions.mls_encode(writer)?;
+
+        if let Some(ref group_id) = self.group_id {
+            mls_rs_codec::byte_vec::mls_encode(group_id, writer)?;
+        }
+
+        if let Some(leaf_index) = self.leaf_index {
+            leaf_index.mls_encode(writer)?;
+        }
+
+        Ok(())
+    }
+}
+
+#[derive(Clone, Debug, Default)]
+pub(crate) struct LeafNodeSigningContext<'a> {
+    pub group_id: Option<&'a [u8]>,
+    pub leaf_index: Option<u32>,
+}
+
+impl<'a> From<(&'a [u8], u32)> for LeafNodeSigningContext<'a> {
+    fn from((group_id, leaf_index): (&'a [u8], u32)) -> Self {
+        Self {
+            group_id: Some(group_id),
+            leaf_index: Some(leaf_index),
+        }
+    }
+}
+
+impl<'a> Signable<'a> for LeafNode {
+    const SIGN_LABEL: &'static str = "LeafNodeTBS";
+
+    type SigningContext = LeafNodeSigningContext<'a>;
+
+    fn signature(&self) -> &[u8] {
+        &self.signature
+    }
+
+    fn signable_content(
+        &self,
+        context: &Self::SigningContext,
+    ) -> Result<Vec<u8>, mls_rs_codec::Error> {
+        LeafNodeTBS {
+            public_key: &self.public_key,
+            signing_identity: &self.signing_identity,
+            capabilities: &self.capabilities,
+            leaf_node_source: &self.leaf_node_source,
+            extensions: &self.extensions,
+            group_id: context.group_id,
+            leaf_index: context.leaf_index,
+        }
+        .mls_encode_to_vec()
+    }
+
+    fn write_signature(&mut self, signature: Vec<u8>) {
+        self.signature = signature
+    }
+}
+
+#[cfg(test)]
+pub(crate) mod test_utils {
+    use alloc::vec;
+    use mls_rs_core::identity::{BasicCredential, CredentialType};
+
+    use crate::{
+        cipher_suite::CipherSuite,
+        crypto::test_utils::{test_cipher_suite_provider, TestCryptoProvider},
+        identity::test_utils::{get_test_signing_identity, BasicWithCustomProvider},
+    };
+
+    use crate::extension::ApplicationIdExt;
+
+    use super::*;
+
+    #[allow(unused)]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn get_test_node(
+        cipher_suite: CipherSuite,
+        signing_identity: SigningIdentity,
+        secret: &SignatureSecretKey,
+        capabilities: Option<Capabilities>,
+        extensions: Option<ExtensionList>,
+    ) -> (LeafNode, HpkeSecretKey) {
+        get_test_node_with_lifetime(
+            cipher_suite,
+            signing_identity,
+            secret,
+            capabilities.unwrap_or_else(get_test_capabilities),
+            extensions.unwrap_or_default(),
+            Lifetime::years(1).unwrap(),
+        )
+        .await
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn get_test_node_with_lifetime(
+        cipher_suite: CipherSuite,
+        signing_identity: SigningIdentity,
+        secret: &SignatureSecretKey,
+        capabilities: Capabilities,
+        extensions: ExtensionList,
+        lifetime: Lifetime,
+    ) -> (LeafNode, HpkeSecretKey) {
+        let properties = ConfigProperties {
+            capabilities,
+            extensions,
+        };
+
+        LeafNode::generate(
+            &test_cipher_suite_provider(cipher_suite),
+            properties,
+            signing_identity,
+            secret,
+            lifetime,
+        )
+        .await
+        .unwrap()
+    }
+
+    #[allow(unused)]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn get_basic_test_node(cipher_suite: CipherSuite, id: &str) -> LeafNode {
+        get_basic_test_node_sig_key(cipher_suite, id).await.0
+    }
+
+    #[allow(unused)]
+    pub fn default_properties() -> ConfigProperties {
+        ConfigProperties {
+            capabilities: get_test_capabilities(),
+            extensions: Default::default(),
+        }
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn get_basic_test_node_capabilities(
+        cipher_suite: CipherSuite,
+        id: &str,
+        capabilities: Capabilities,
+    ) -> (LeafNode, HpkeSecretKey, SignatureSecretKey) {
+        let (signing_identity, signature_key) =
+            get_test_signing_identity(cipher_suite, id.as_bytes()).await;
+
+        LeafNode::generate(
+            &test_cipher_suite_provider(cipher_suite),
+            ConfigProperties {
+                capabilities,
+                extensions: Default::default(),
+            },
+            signing_identity,
+            &signature_key,
+            Lifetime::years(1).unwrap(),
+        )
+        .await
+        .map(|(leaf, hpke_secret_key)| (leaf, hpke_secret_key, signature_key))
+        .unwrap()
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn get_basic_test_node_sig_key(
+        cipher_suite: CipherSuite,
+        id: &str,
+    ) -> (LeafNode, HpkeSecretKey, SignatureSecretKey) {
+        get_basic_test_node_capabilities(cipher_suite, id, get_test_capabilities()).await
+    }
+
+    #[allow(unused)]
+    pub fn get_test_extensions() -> ExtensionList {
+        let mut extension_list = ExtensionList::new();
+
+        extension_list
+            .set_from(ApplicationIdExt {
+                identifier: b"identifier".to_vec(),
+            })
+            .unwrap();
+
+        extension_list
+    }
+
+    pub fn get_test_capabilities() -> Capabilities {
+        Capabilities {
+            credentials: vec![
+                BasicCredential::credential_type(),
+                CredentialType::from(BasicWithCustomProvider::CUSTOM_CREDENTIAL_TYPE),
+            ],
+            cipher_suites: TestCryptoProvider::all_supported_cipher_suites(),
+            ..Default::default()
+        }
+    }
+
+    #[allow(unused)]
+    pub fn get_test_client_identity(leaf: &LeafNode) -> Vec<u8> {
+        leaf.signing_identity
+            .credential
+            .mls_encode_to_vec()
+            .unwrap()
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::test_utils::*;
+    use super::*;
+
+    use crate::client::test_utils::TEST_CIPHER_SUITE;
+    use crate::crypto::test_utils::test_cipher_suite_provider;
+    use crate::crypto::test_utils::TestCryptoProvider;
+    use crate::group::test_utils::random_bytes;
+    use crate::identity::test_utils::get_test_signing_identity;
+    use assert_matches::assert_matches;
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_node_generation() {
+        let capabilities = get_test_capabilities();
+        let extensions = get_test_extensions();
+        let lifetime = Lifetime::years(1).unwrap();
+
+        for cipher_suite in TestCryptoProvider::all_supported_cipher_suites() {
+            let (signing_identity, secret) = get_test_signing_identity(cipher_suite, b"foo").await;
+
+            let (leaf_node, secret_key) = get_test_node_with_lifetime(
+                cipher_suite,
+                signing_identity.clone(),
+                &secret,
+                capabilities.clone(),
+                extensions.clone(),
+                lifetime.clone(),
+            )
+            .await;
+
+            assert_eq!(leaf_node.ungreased_capabilities(), capabilities);
+            assert_eq!(leaf_node.ungreased_extensions(), extensions);
+            assert_eq!(leaf_node.signing_identity, signing_identity);
+
+            assert_matches!(
+                &leaf_node.leaf_node_source,
+                LeafNodeSource::KeyPackage(lt) if lt == &lifetime,
+                "Expected {:?}, got {:?}", LeafNodeSource::KeyPackage(lifetime),
+                leaf_node.leaf_node_source
+            );
+
+            let provider = test_cipher_suite_provider(cipher_suite);
+
+            // Verify that the hpke key pair generated will work
+            let test_data = random_bytes(32);
+
+            let sealed = provider
+                .hpke_seal(&leaf_node.public_key, &[], None, &test_data)
+                .await
+                .unwrap();
+
+            let opened = provider
+                .hpke_open(&sealed, &secret_key, &leaf_node.public_key, &[], None)
+                .await
+                .unwrap();
+
+            assert_eq!(opened, test_data);
+
+            leaf_node
+                .verify(
+                    &test_cipher_suite_provider(cipher_suite),
+                    &signing_identity.signature_key,
+                    &LeafNodeSigningContext::default(),
+                )
+                .await
+                .unwrap();
+        }
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_node_generation_randomness() {
+        let cipher_suite = TEST_CIPHER_SUITE;
+
+        let (signing_identity, secret) = get_test_signing_identity(cipher_suite, b"foo").await;
+
+        let (first_leaf, first_secret) =
+            get_test_node(cipher_suite, signing_identity.clone(), &secret, None, None).await;
+
+        for _ in 0..100 {
+            let (next_leaf, next_secret) =
+                get_test_node(cipher_suite, signing_identity.clone(), &secret, None, None).await;
+
+            assert_ne!(first_secret, next_secret);
+            assert_ne!(first_leaf.public_key, next_leaf.public_key);
+        }
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_node_update_no_meta_changes() {
+        for cipher_suite in TestCryptoProvider::all_supported_cipher_suites() {
+            let cipher_suite_provider = test_cipher_suite_provider(cipher_suite);
+
+            let (signing_identity, secret) = get_test_signing_identity(cipher_suite, b"foo").await;
+
+            let (mut leaf, leaf_secret) =
+                get_test_node(cipher_suite, signing_identity.clone(), &secret, None, None).await;
+
+            let original_leaf = leaf.clone();
+
+            let new_secret = leaf
+                .update(
+                    &cipher_suite_provider,
+                    b"group",
+                    0,
+                    default_properties(),
+                    None,
+                    &secret,
+                )
+                .await
+                .unwrap();
+
+            assert_ne!(new_secret, leaf_secret);
+            assert_ne!(original_leaf.public_key, leaf.public_key);
+
+            assert_eq!(
+                leaf.ungreased_capabilities(),
+                original_leaf.ungreased_capabilities()
+            );
+
+            assert_eq!(
+                leaf.ungreased_extensions(),
+                original_leaf.ungreased_extensions()
+            );
+
+            assert_eq!(leaf.signing_identity, original_leaf.signing_identity);
+            assert_matches!(&leaf.leaf_node_source, LeafNodeSource::Update);
+
+            leaf.verify(
+                &cipher_suite_provider,
+                &signing_identity.signature_key,
+                &(b"group".as_slice(), 0).into(),
+            )
+            .await
+            .unwrap();
+        }
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_node_update_meta_changes() {
+        let cipher_suite = TEST_CIPHER_SUITE;
+
+        let (signing_identity, secret) = get_test_signing_identity(cipher_suite, b"foo").await;
+
+        let new_properties = ConfigProperties {
+            capabilities: get_test_capabilities(),
+            extensions: get_test_extensions(),
+        };
+
+        let (mut leaf, _) =
+            get_test_node(cipher_suite, signing_identity, &secret, None, None).await;
+
+        leaf.update(
+            &test_cipher_suite_provider(cipher_suite),
+            b"group",
+            0,
+            new_properties.clone(),
+            None,
+            &secret,
+        )
+        .await
+        .unwrap();
+
+        assert_eq!(leaf.ungreased_capabilities(), new_properties.capabilities);
+        assert_eq!(leaf.ungreased_extensions(), new_properties.extensions);
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_node_commit_no_meta_changes() {
+        for cipher_suite in TestCryptoProvider::all_supported_cipher_suites() {
+            let cipher_suite_provider = test_cipher_suite_provider(cipher_suite);
+
+            let (signing_identity, secret) = get_test_signing_identity(cipher_suite, b"foo").await;
+
+            let (mut leaf, leaf_secret) =
+                get_test_node(cipher_suite, signing_identity.clone(), &secret, None, None).await;
+
+            let original_leaf = leaf.clone();
+
+            let new_secret = leaf
+                .commit(
+                    &cipher_suite_provider,
+                    b"group",
+                    0,
+                    default_properties(),
+                    None,
+                    &secret,
+                )
+                .await
+                .unwrap();
+
+            assert_ne!(new_secret, leaf_secret);
+            assert_ne!(original_leaf.public_key, leaf.public_key);
+
+            assert_eq!(
+                leaf.ungreased_capabilities(),
+                original_leaf.ungreased_capabilities()
+            );
+
+            assert_eq!(
+                leaf.ungreased_extensions(),
+                original_leaf.ungreased_extensions()
+            );
+
+            assert_eq!(leaf.signing_identity, original_leaf.signing_identity);
+
+            leaf.verify(
+                &cipher_suite_provider,
+                &signing_identity.signature_key,
+                &(b"group".as_slice(), 0).into(),
+            )
+            .await
+            .unwrap();
+        }
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_node_commit_meta_changes() {
+        let cipher_suite = TEST_CIPHER_SUITE;
+
+        let (signing_identity, secret) = get_test_signing_identity(cipher_suite, b"foo").await;
+        let (mut leaf, _) =
+            get_test_node(cipher_suite, signing_identity, &secret, None, None).await;
+
+        let new_properties = ConfigProperties {
+            capabilities: get_test_capabilities(),
+            extensions: get_test_extensions(),
+        };
+
+        // The new identity has a fresh public key
+        let new_signing_identity = get_test_signing_identity(cipher_suite, b"foo").await.0;
+
+        leaf.commit(
+            &test_cipher_suite_provider(cipher_suite),
+            b"group",
+            0,
+            new_properties.clone(),
+            Some(new_signing_identity.clone()),
+            &secret,
+        )
+        .await
+        .unwrap();
+
+        assert_eq!(leaf.capabilities, new_properties.capabilities);
+        assert_eq!(leaf.extensions, new_properties.extensions);
+        assert_eq!(leaf.signing_identity, new_signing_identity);
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn context_is_signed() {
+        let provider = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+
+        let (signing_identity, secret) = get_test_signing_identity(TEST_CIPHER_SUITE, b"foo").await;
+
+        let (mut leaf, _) = get_test_node(
+            TEST_CIPHER_SUITE,
+            signing_identity.clone(),
+            &secret,
+            None,
+            None,
+        )
+        .await;
+
+        leaf.sign(&provider, &secret, &(b"foo".as_slice(), 0).into())
+            .await
+            .unwrap();
+
+        let res = leaf
+            .verify(
+                &provider,
+                &signing_identity.signature_key,
+                &(b"foo".as_slice(), 1).into(),
+            )
+            .await;
+
+        assert_matches!(res, Err(MlsError::InvalidSignature));
+
+        let res = leaf
+            .verify(
+                &provider,
+                &signing_identity.signature_key,
+                &(b"bar".as_slice(), 0).into(),
+            )
+            .await;
+
+        assert_matches!(res, Err(MlsError::InvalidSignature));
+    }
+}
diff --git a/src/tree_kem/leaf_node_validator.rs b/src/tree_kem/leaf_node_validator.rs
new file mode 100644
index 0000000..17742ec
--- /dev/null
+++ b/src/tree_kem/leaf_node_validator.rs
@@ -0,0 +1,708 @@
+// 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::leaf_node::{LeafNode, LeafNodeSigningContext, LeafNodeSource};
+use crate::client::MlsError;
+use crate::CipherSuiteProvider;
+use crate::{signer::Signable, time::MlsTime};
+use mls_rs_core::{error::IntoAnyError, extension::ExtensionList, identity::IdentityProvider};
+
+use crate::extension::RequiredCapabilitiesExt;
+
+#[cfg(feature = "by_ref_proposal")]
+use crate::extension::ExternalSendersExt;
+
+pub enum ValidationContext<'a> {
+    Add(Option<MlsTime>),
+    Update((&'a [u8], u32, Option<MlsTime>)),
+    Commit((&'a [u8], u32, Option<MlsTime>)),
+}
+
+impl<'a> ValidationContext<'a> {
+    fn signing_context(&self) -> LeafNodeSigningContext {
+        match *self {
+            ValidationContext::Add(_) => Default::default(),
+            ValidationContext::Update((group_id, leaf_index, _)) => (group_id, leaf_index).into(),
+            ValidationContext::Commit((group_id, leaf_index, _)) => (group_id, leaf_index).into(),
+        }
+    }
+
+    fn generation_time(&self) -> Option<MlsTime> {
+        match *self {
+            ValidationContext::Add(t) => t,
+            ValidationContext::Update((_, _, t)) => t,
+            ValidationContext::Commit((_, _, t)) => t,
+        }
+    }
+}
+
+#[derive(Clone, Debug)]
+pub struct LeafNodeValidator<'a, C, CP>
+where
+    C: IdentityProvider,
+    CP: CipherSuiteProvider,
+{
+    cipher_suite_provider: &'a CP,
+    identity_provider: &'a C,
+    group_context_extensions: Option<&'a ExtensionList>,
+}
+
+impl<'a, C: IdentityProvider, CP: CipherSuiteProvider> LeafNodeValidator<'a, C, CP> {
+    pub fn new(
+        cipher_suite_provider: &'a CP,
+        identity_provider: &'a C,
+        group_context_extensions: Option<&'a ExtensionList>,
+    ) -> Self {
+        Self {
+            cipher_suite_provider,
+            identity_provider,
+            group_context_extensions,
+        }
+    }
+
+    fn check_context(
+        &self,
+        leaf_node: &LeafNode,
+        context: &ValidationContext,
+    ) -> Result<(), MlsError> {
+        // Context specific checks
+        match context {
+            ValidationContext::Add(time) => {
+                // If the context is add, and we specified a time to check for lifetime, verify it
+                if let LeafNodeSource::KeyPackage(lifetime) = &leaf_node.leaf_node_source {
+                    if let Some(current_time) = time {
+                        if !lifetime.within_lifetime(*current_time) {
+                            return Err(MlsError::InvalidLifetime);
+                        }
+                    }
+                } else {
+                    // If the leaf_node_source is anything other than Add it is invalid
+                    return Err(MlsError::InvalidLeafNodeSource);
+                }
+            }
+            ValidationContext::Update(_) => {
+                // If the leaf_node_source is anything other than Update it is invalid
+                if !matches!(leaf_node.leaf_node_source, LeafNodeSource::Update) {
+                    return Err(MlsError::InvalidLeafNodeSource);
+                }
+            }
+            ValidationContext::Commit(_) => {
+                // If the leaf_node_source is anything other than Commit it is invalid
+                if !matches!(leaf_node.leaf_node_source, LeafNodeSource::Commit(_)) {
+                    return Err(MlsError::InvalidLeafNodeSource);
+                }
+            }
+        }
+
+        Ok(())
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn revalidate(
+        &self,
+        leaf_node: &LeafNode,
+        group_id: &[u8],
+        leaf_index: u32,
+    ) -> Result<(), MlsError> {
+        let context = match leaf_node.leaf_node_source {
+            LeafNodeSource::KeyPackage(_) => ValidationContext::Add(None),
+            LeafNodeSource::Update => ValidationContext::Update((group_id, leaf_index, None)),
+            LeafNodeSource::Commit(_) => ValidationContext::Commit((group_id, leaf_index, None)),
+        };
+
+        self.check_if_valid(leaf_node, context).await
+    }
+
+    pub fn validate_required_capabilities(&self, leaf_node: &LeafNode) -> Result<(), MlsError> {
+        let Some(required_capabilities) = self
+            .group_context_extensions
+            .and_then(|exts| exts.get_as::<RequiredCapabilitiesExt>().transpose())
+            .transpose()?
+        else {
+            return Ok(());
+        };
+
+        for extension in &required_capabilities.extensions {
+            if !leaf_node.capabilities.extensions.contains(extension) {
+                return Err(MlsError::RequiredExtensionNotFound(*extension));
+            }
+        }
+
+        for proposal in &required_capabilities.proposals {
+            if !leaf_node.capabilities.proposals.contains(proposal) {
+                return Err(MlsError::RequiredProposalNotFound(*proposal));
+            }
+        }
+
+        for credential in &required_capabilities.credentials {
+            if !leaf_node.capabilities.credentials.contains(credential) {
+                return Err(MlsError::RequiredCredentialNotFound(*credential));
+            }
+        }
+
+        Ok(())
+    }
+
+    #[cfg(feature = "by_ref_proposal")]
+    pub fn validate_external_senders_ext_credentials(
+        &self,
+        leaf_node: &LeafNode,
+    ) -> Result<(), MlsError> {
+        let Some(ext) = self
+            .group_context_extensions
+            .and_then(|exts| exts.get_as::<ExternalSendersExt>().transpose())
+            .transpose()?
+        else {
+            return Ok(());
+        };
+
+        ext.allowed_senders.iter().try_for_each(|sender| {
+            let cred_type = sender.credential.credential_type();
+            leaf_node
+                .capabilities
+                .credentials
+                .contains(&cred_type)
+                .then_some(())
+                .ok_or(MlsError::RequiredCredentialNotFound(cred_type))
+        })
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(crate) async fn check_if_valid(
+        &self,
+        leaf_node: &LeafNode,
+        context: ValidationContext<'_>,
+    ) -> Result<(), MlsError> {
+        // Check that we are validating within the proper context
+        self.check_context(leaf_node, &context)?;
+
+        // Verify the credential
+        self.identity_provider
+            .validate_member(
+                &leaf_node.signing_identity,
+                context.generation_time(),
+                self.group_context_extensions,
+            )
+            .await
+            .map_err(|e| MlsError::IdentityProviderError(e.into_any_error()))?;
+
+        // Verify that the credential signed the leaf node
+        leaf_node
+            .verify(
+                self.cipher_suite_provider,
+                &leaf_node.signing_identity.signature_key,
+                &context.signing_context(),
+            )
+            .await?;
+
+        // If required capabilities are specified, verify the leaf node meets the requirements
+        self.validate_required_capabilities(leaf_node)?;
+
+        // If there are extensions, make sure they are referenced in the capabilities field
+        for one_ext in &*leaf_node.extensions {
+            if !leaf_node
+                .capabilities
+                .extensions
+                .contains(&one_ext.extension_type)
+            {
+                return Err(MlsError::ExtensionNotInCapabilities(one_ext.extension_type));
+            }
+        }
+
+        // Verify that group extensions are supported by the leaf
+        self.group_context_extensions
+            .into_iter()
+            .flat_map(|exts| &**exts)
+            .map(|ext| ext.extension_type)
+            .find(|ext_type| {
+                !ext_type.is_default() && !leaf_node.capabilities.extensions.contains(ext_type)
+            })
+            .map(MlsError::UnsupportedGroupExtension)
+            .map_or(Ok(()), Err)?;
+
+        #[cfg(feature = "by_ref_proposal")]
+        self.validate_external_senders_ext_credentials(leaf_node)?;
+
+        Ok(())
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use crate::crypto::test_utils::try_test_cipher_suite_provider;
+    use crate::extension::MlsExtension;
+    use alloc::vec;
+    use assert_matches::assert_matches;
+    #[cfg(feature = "std")]
+    use core::time::Duration;
+    use mls_rs_core::crypto::CipherSuite;
+    use mls_rs_core::group::ProposalType;
+
+    use super::*;
+
+    use crate::client::test_utils::TEST_CIPHER_SUITE;
+    use crate::crypto::test_utils::test_cipher_suite_provider;
+    use crate::crypto::test_utils::TestCryptoProvider;
+    use crate::crypto::SignatureSecretKey;
+    use crate::extension::test_utils::TestExtension;
+    use crate::group::test_utils::random_bytes;
+    use crate::identity::basic::BasicCredential;
+    use crate::identity::basic::BasicIdentityProvider;
+    use crate::identity::test_utils::get_test_signing_identity;
+    use crate::tree_kem::leaf_node::test_utils::*;
+    use crate::tree_kem::leaf_node_validator::test_utils::FailureIdentityProvider;
+    use crate::tree_kem::Capabilities;
+    use crate::ExtensionList;
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn get_test_add_node() -> (LeafNode, SignatureSecretKey) {
+        let (signing_identity, secret) = get_test_signing_identity(TEST_CIPHER_SUITE, b"foo").await;
+
+        let (leaf_node, _) =
+            get_test_node(TEST_CIPHER_SUITE, signing_identity, &secret, None, None).await;
+
+        (leaf_node, secret)
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_basic_add_validation() {
+        let cipher_suite_provider = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+
+        let (leaf_node, _) = get_test_add_node().await;
+
+        let test_validator =
+            LeafNodeValidator::new(&cipher_suite_provider, &BasicIdentityProvider, None);
+
+        let res = test_validator
+            .check_if_valid(&leaf_node, ValidationContext::Add(None))
+            .await;
+
+        assert_matches!(res, Ok(_));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_failed_validation() {
+        let cipher_suite_provider = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+        let (leaf_node, _) = get_test_add_node().await;
+
+        let fail_test_validator =
+            LeafNodeValidator::new(&cipher_suite_provider, &FailureIdentityProvider, None);
+
+        let res = fail_test_validator
+            .check_if_valid(&leaf_node, ValidationContext::Add(None))
+            .await;
+
+        assert_matches!(res, Err(MlsError::IdentityProviderError(_)));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_basic_update_validation() {
+        let cipher_suite_provider = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+        let group_id = b"group_id";
+
+        let (mut leaf_node, secret) = get_test_add_node().await;
+
+        leaf_node
+            .update(
+                &cipher_suite_provider,
+                group_id,
+                0,
+                // TODO remove identity from input
+                default_properties(),
+                None,
+                &secret,
+            )
+            .await
+            .unwrap();
+
+        let test_validator =
+            LeafNodeValidator::new(&cipher_suite_provider, &BasicIdentityProvider, None);
+
+        let res = test_validator
+            .check_if_valid(&leaf_node, ValidationContext::Update((group_id, 0, None)))
+            .await;
+
+        assert_matches!(res, Ok(_));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_basic_commit_validation() {
+        let cipher_suite_provider = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+        let group_id = b"group_id";
+
+        let (mut leaf_node, secret) = get_test_add_node().await;
+
+        leaf_node.leaf_node_source = LeafNodeSource::Commit(hex!("f00d").into());
+
+        leaf_node
+            .commit(
+                &cipher_suite_provider,
+                group_id,
+                0,
+                default_properties(),
+                None,
+                &secret,
+            )
+            .await
+            .unwrap();
+
+        let test_validator =
+            LeafNodeValidator::new(&cipher_suite_provider, &BasicIdentityProvider, None);
+
+        let res = test_validator
+            .check_if_valid(&leaf_node, ValidationContext::Commit((group_id, 0, None)))
+            .await;
+
+        assert_matches!(res, Ok(_));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_incorrect_context() {
+        let cipher_suite_provider = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+
+        let test_validator =
+            LeafNodeValidator::new(&cipher_suite_provider, &BasicIdentityProvider, None);
+
+        let (mut leaf_node, secret) = get_test_add_node().await;
+
+        let res = test_validator
+            .check_if_valid(&leaf_node, ValidationContext::Update((b"foo", 0, None)))
+            .await;
+
+        assert_matches!(res, Err(MlsError::InvalidLeafNodeSource));
+
+        let res = test_validator
+            .check_if_valid(&leaf_node, ValidationContext::Commit((b"foo", 0, None)))
+            .await;
+
+        assert_matches!(res, Err(MlsError::InvalidLeafNodeSource));
+
+        leaf_node
+            .update(
+                &cipher_suite_provider,
+                b"foo",
+                0,
+                default_properties(),
+                None,
+                &secret,
+            )
+            .await
+            .unwrap();
+
+        let res = test_validator
+            .check_if_valid(&leaf_node, ValidationContext::Add(None))
+            .await;
+
+        assert_matches!(res, Err(MlsError::InvalidLeafNodeSource));
+
+        let res = test_validator
+            .check_if_valid(&leaf_node, ValidationContext::Commit((b"foo", 0, None)))
+            .await;
+
+        assert_matches!(res, Err(MlsError::InvalidLeafNodeSource));
+
+        leaf_node.leaf_node_source = LeafNodeSource::Commit(hex!("f00d").into());
+
+        leaf_node
+            .commit(
+                &cipher_suite_provider,
+                b"foo",
+                0,
+                default_properties(),
+                None,
+                &secret,
+            )
+            .await
+            .unwrap();
+
+        let res = test_validator
+            .check_if_valid(&leaf_node, ValidationContext::Add(None))
+            .await;
+
+        assert_matches!(res, Err(MlsError::InvalidLeafNodeSource));
+
+        let res = test_validator
+            .check_if_valid(&leaf_node, ValidationContext::Update((b"foo", 0, None)))
+            .await;
+
+        assert_matches!(res, Err(MlsError::InvalidLeafNodeSource));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_bad_signature() {
+        for cipher_suite in TestCryptoProvider::all_supported_cipher_suites() {
+            let cipher_suite_provider = test_cipher_suite_provider(cipher_suite);
+
+            let (signing_identity, secret) = get_test_signing_identity(cipher_suite, b"foo").await;
+
+            let (mut leaf_node, _) =
+                get_test_node(cipher_suite, signing_identity, &secret, None, None).await;
+
+            leaf_node.signature = random_bytes(leaf_node.signature.len());
+
+            let test_validator =
+                LeafNodeValidator::new(&cipher_suite_provider, &BasicIdentityProvider, None);
+
+            let res = test_validator
+                .check_if_valid(&leaf_node, ValidationContext::Add(None))
+                .await;
+
+            assert_matches!(res, Err(MlsError::InvalidSignature));
+        }
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_capabilities_mismatch() {
+        let (signing_identity, secret) = get_test_signing_identity(TEST_CIPHER_SUITE, b"foo").await;
+
+        let mut extensions = ExtensionList::new();
+
+        extensions.set_from(TestExtension::from(0)).unwrap();
+
+        let capabilities = Capabilities {
+            credentials: vec![BasicCredential::credential_type()],
+            ..Default::default()
+        };
+
+        let (leaf_node, _) = get_test_node(
+            TEST_CIPHER_SUITE,
+            signing_identity,
+            &secret,
+            Some(capabilities),
+            Some(extensions),
+        )
+        .await;
+
+        let cipher_suite_provider = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+
+        let test_validator =
+            LeafNodeValidator::new(&cipher_suite_provider, &BasicIdentityProvider, None);
+
+        let res = test_validator
+            .check_if_valid(&leaf_node, ValidationContext::Add(None))
+            .await;
+
+        assert_matches!(res,
+            Err(MlsError::ExtensionNotInCapabilities(ext)) if ext == 42.into());
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_cipher_suite_mismatch() {
+        for another_cipher_suite in CipherSuite::all().filter(|cs| cs != &TEST_CIPHER_SUITE) {
+            if let Some(cs) = try_test_cipher_suite_provider(*another_cipher_suite) {
+                let (leaf_node, _) = get_test_add_node().await;
+
+                let test_validator = LeafNodeValidator::new(&cs, &BasicIdentityProvider, None);
+
+                let res = test_validator
+                    .check_if_valid(&leaf_node, ValidationContext::Add(None))
+                    .await;
+
+                assert_matches!(res, Err(MlsError::InvalidSignature));
+            }
+        }
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_required_extension() {
+        let required_capabilities = RequiredCapabilitiesExt {
+            extensions: vec![43.into()],
+            ..Default::default()
+        };
+
+        let (leaf_node, _) = get_test_add_node().await;
+
+        let cipher_suite_provider = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+
+        let group_context_extensions =
+            core::iter::once(required_capabilities.into_extension().unwrap()).collect();
+
+        let test_validator = LeafNodeValidator::new(
+            &cipher_suite_provider,
+            &BasicIdentityProvider,
+            Some(&group_context_extensions),
+        );
+
+        let res = test_validator
+            .check_if_valid(&leaf_node, ValidationContext::Add(None))
+            .await;
+
+        assert_matches!(
+            res,
+            Err(MlsError::RequiredExtensionNotFound(v)) if v == 43.into()
+        );
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_required_proposal() {
+        let required_capabilities = RequiredCapabilitiesExt {
+            proposals: vec![42.into()],
+            ..Default::default()
+        };
+
+        let cipher_suite_provider = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+
+        let (leaf_node, _) = get_test_add_node().await;
+
+        let group_context_extensions =
+            core::iter::once(required_capabilities.into_extension().unwrap()).collect();
+
+        let test_validator = LeafNodeValidator::new(
+            &cipher_suite_provider,
+            &BasicIdentityProvider,
+            Some(&group_context_extensions),
+        );
+
+        let res = test_validator
+            .check_if_valid(&leaf_node, ValidationContext::Add(None))
+            .await;
+
+        assert_matches!(
+            res,
+            Err(MlsError::RequiredProposalNotFound(p)) if p == ProposalType::new(42)
+        );
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_required_credential() {
+        let required_capabilities = RequiredCapabilitiesExt {
+            credentials: vec![0.into()],
+            ..Default::default()
+        };
+
+        let (leaf_node, _) = get_test_add_node().await;
+
+        let cipher_suite_provider = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+
+        let group_context_extensions =
+            core::iter::once(required_capabilities.into_extension().unwrap()).collect();
+
+        let test_validator = LeafNodeValidator::new(
+            &cipher_suite_provider,
+            &BasicIdentityProvider,
+            Some(&group_context_extensions),
+        );
+
+        let res = test_validator
+            .check_if_valid(&leaf_node, ValidationContext::Add(None))
+            .await;
+
+        assert_matches!(res,
+            Err(MlsError::RequiredCredentialNotFound(ext)) if ext == 0.into()
+        );
+    }
+
+    #[cfg(feature = "std")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_add_lifetime() {
+        let (leaf_node, _) = get_test_add_node().await;
+
+        let cipher_suite_provider = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+
+        let test_validator =
+            LeafNodeValidator::new(&cipher_suite_provider, &BasicIdentityProvider, None);
+
+        let good_lifetime = MlsTime::now();
+
+        let over_one_year = good_lifetime.seconds_since_epoch() + (86400 * 366);
+
+        let bad_lifetime = MlsTime::from_duration_since_epoch(Duration::from_secs(over_one_year));
+
+        let res = test_validator
+            .check_if_valid(&leaf_node, ValidationContext::Add(Some(good_lifetime)))
+            .await;
+
+        assert_matches!(res, Ok(()));
+
+        let res = test_validator
+            .check_if_valid(&leaf_node, ValidationContext::Add(Some(bad_lifetime)))
+            .await;
+
+        assert_matches!(res, Err(MlsError::InvalidLifetime));
+    }
+}
+
+#[cfg(test)]
+pub(crate) mod test_utils {
+    use alloc::vec;
+    use alloc::{boxed::Box, vec::Vec};
+    use mls_rs_codec::MlsEncode;
+    use mls_rs_core::{
+        error::IntoAnyError,
+        extension::ExtensionList,
+        identity::{BasicCredential, IdentityProvider},
+    };
+
+    use crate::{identity::SigningIdentity, time::MlsTime};
+
+    #[derive(Clone, Debug, Default)]
+    pub struct FailureIdentityProvider;
+
+    #[cfg(feature = "by_ref_proposal")]
+    impl FailureIdentityProvider {
+        pub fn new() -> Self {
+            Self
+        }
+    }
+
+    #[derive(Debug)]
+    #[cfg_attr(feature = "std", derive(thiserror::Error))]
+    #[cfg_attr(feature = "std", error("test error"))]
+    pub struct TestFailureError;
+
+    impl IntoAnyError for TestFailureError {
+        #[cfg(feature = "std")]
+        fn into_dyn_error(self) -> Result<Box<dyn std::error::Error + Send + Sync>, Self> {
+            Ok(self.into())
+        }
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    #[cfg_attr(mls_build_async, maybe_async::must_be_async)]
+    impl IdentityProvider for FailureIdentityProvider {
+        type Error = TestFailureError;
+
+        async fn validate_member(
+            &self,
+            _signing_identity: &SigningIdentity,
+            _timestamp: Option<MlsTime>,
+            _extensions: Option<&ExtensionList>,
+        ) -> Result<(), Self::Error> {
+            Err(TestFailureError)
+        }
+
+        async fn validate_external_sender(
+            &self,
+            _signing_identity: &SigningIdentity,
+            _timestamp: Option<MlsTime>,
+            _extensions: Option<&ExtensionList>,
+        ) -> Result<(), Self::Error> {
+            Err(TestFailureError)
+        }
+
+        #[cfg_attr(coverage_nightly, coverage(off))]
+        async fn identity(
+            &self,
+            signing_id: &SigningIdentity,
+            _extensions: &ExtensionList,
+        ) -> Result<Vec<u8>, Self::Error> {
+            Ok(signing_id.credential.mls_encode_to_vec().unwrap())
+        }
+
+        #[cfg_attr(coverage_nightly, coverage(off))]
+        async fn valid_successor(
+            &self,
+            _predecessor: &SigningIdentity,
+            _successor: &SigningIdentity,
+            _extensions: &ExtensionList,
+        ) -> Result<bool, Self::Error> {
+            Err(TestFailureError)
+        }
+
+        #[cfg_attr(coverage_nightly, coverage(off))]
+        fn supported_types(&self) -> Vec<crate::identity::CredentialType> {
+            vec![BasicCredential::credential_type()]
+        }
+    }
+}
diff --git a/src/tree_kem/lifetime.rs b/src/tree_kem/lifetime.rs
new file mode 100644
index 0000000..d508ad6
--- /dev/null
+++ b/src/tree_kem/lifetime.rs
@@ -0,0 +1,119 @@
+// 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, time::MlsTime};
+use mls_rs_codec::{MlsDecode, MlsEncode, MlsSize};
+
+#[derive(Clone, Debug, PartialEq, Eq, MlsSize, MlsEncode, MlsDecode, Default)]
+#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[non_exhaustive]
+pub struct Lifetime {
+    pub not_before: u64,
+    pub not_after: u64,
+}
+
+impl Lifetime {
+    pub fn new(not_before: u64, not_after: u64) -> Lifetime {
+        Lifetime {
+            not_before,
+            not_after,
+        }
+    }
+
+    pub fn seconds(s: u64) -> Result<Self, MlsError> {
+        #[cfg(feature = "std")]
+        let not_before = MlsTime::now().seconds_since_epoch();
+        #[cfg(not(feature = "std"))]
+        // There is no clock on no_std, this is here just so that we can run tests.
+        let not_before = 3600u64;
+
+        let not_after = not_before.checked_add(s).ok_or(MlsError::TimeOverflow)?;
+
+        Ok(Lifetime {
+            // Subtract 1 hour to address time difference between machines
+            not_before: not_before - 3600,
+            not_after,
+        })
+    }
+
+    pub fn days(d: u32) -> Result<Self, MlsError> {
+        Self::seconds((d * 86400) as u64)
+    }
+
+    pub fn years(y: u8) -> Result<Self, MlsError> {
+        Self::days(365 * y as u32)
+    }
+
+    pub(crate) fn within_lifetime(&self, time: MlsTime) -> bool {
+        let since_epoch = time.seconds_since_epoch();
+        since_epoch >= self.not_before && since_epoch <= self.not_after
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use core::time::Duration;
+
+    use super::*;
+    use assert_matches::assert_matches;
+
+    #[test]
+    fn test_lifetime_overflow() {
+        let res = Lifetime::seconds(u64::MAX);
+        assert_matches!(res, Err(MlsError::TimeOverflow))
+    }
+
+    #[test]
+    fn test_seconds() {
+        let seconds = 10;
+        let lifetime = Lifetime::seconds(seconds).unwrap();
+        assert_eq!(lifetime.not_after - lifetime.not_before, 3610);
+    }
+
+    #[test]
+    fn test_days() {
+        let days = 2;
+        let lifetime = Lifetime::days(days).unwrap();
+
+        assert_eq!(
+            lifetime.not_after - lifetime.not_before,
+            86400u64 * days as u64 + 3600
+        );
+    }
+
+    #[test]
+    fn test_years() {
+        let years = 2;
+        let lifetime = Lifetime::years(years).unwrap();
+
+        assert_eq!(
+            lifetime.not_after - lifetime.not_before,
+            86400 * 365 * years as u64 + 3600
+        );
+    }
+
+    #[test]
+    fn test_bounds() {
+        let test_lifetime = Lifetime {
+            not_before: 5,
+            not_after: 10,
+        };
+
+        assert!(!test_lifetime
+            .within_lifetime(MlsTime::from_duration_since_epoch(Duration::from_secs(4))));
+
+        assert!(!test_lifetime
+            .within_lifetime(MlsTime::from_duration_since_epoch(Duration::from_secs(11))));
+
+        assert!(test_lifetime
+            .within_lifetime(MlsTime::from_duration_since_epoch(Duration::from_secs(5))));
+
+        assert!(test_lifetime
+            .within_lifetime(MlsTime::from_duration_since_epoch(Duration::from_secs(10))));
+
+        assert!(test_lifetime
+            .within_lifetime(MlsTime::from_duration_since_epoch(Duration::from_secs(6))));
+    }
+}
diff --git a/src/tree_kem/math.rs b/src/tree_kem/math.rs
new file mode 100644
index 0000000..51f82ed
--- /dev/null
+++ b/src/tree_kem/math.rs
@@ -0,0 +1,383 @@
+// 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::Debug, hash::Hash};
+use mls_rs_codec::{MlsDecode, MlsEncode};
+
+use super::node::LeafIndex;
+
+pub trait TreeIndex:
+    Send + Sync + Eq + Clone + Debug + Default + MlsEncode + MlsDecode + Hash + Ord
+{
+    fn root(&self) -> Self;
+
+    fn left_unchecked(&self) -> Self;
+    fn right_unchecked(&self) -> Self;
+
+    fn parent_sibling(&self, leaf_count: &Self) -> Option<ParentSibling<Self>>;
+    fn is_leaf(&self) -> bool;
+    fn is_in_tree(&self, root: &Self) -> bool;
+
+    #[cfg(any(feature = "secret_tree_access", feature = "private_message"))]
+    fn zero() -> Self;
+
+    #[cfg(any(feature = "secret_tree_access", feature = "private_message", test))]
+    fn left(&self) -> Option<Self> {
+        (!self.is_leaf()).then(|| self.left_unchecked())
+    }
+
+    #[cfg(any(feature = "secret_tree_access", feature = "private_message", test))]
+    fn right(&self) -> Option<Self> {
+        (!self.is_leaf()).then(|| self.right_unchecked())
+    }
+
+    fn direct_copath(&self, leaf_count: &Self) -> Vec<CopathNode<Self>> {
+        let root = leaf_count.root();
+
+        if !self.is_in_tree(&root) {
+            return Vec::new();
+        }
+
+        let mut path = Vec::new();
+        let mut parent = self.clone();
+
+        while let Some(ps) = parent.parent_sibling(leaf_count) {
+            path.push(CopathNode::new(ps.parent.clone(), ps.sibling));
+            parent = ps.parent;
+        }
+
+        path
+    }
+}
+
+#[derive(Clone, PartialEq, Eq, Debug)]
+pub struct CopathNode<T> {
+    pub path: T,
+    pub copath: T,
+}
+
+impl<T: Clone + PartialEq + Eq + core::fmt::Debug> CopathNode<T> {
+    pub fn new(path: T, copath: T) -> CopathNode<T> {
+        CopathNode { path, copath }
+    }
+}
+
+#[derive(Clone, PartialEq, Eq, Debug)]
+pub struct ParentSibling<T> {
+    pub parent: T,
+    pub sibling: T,
+}
+
+impl<T: Clone + PartialEq + Eq + core::fmt::Debug> ParentSibling<T> {
+    pub fn new(parent: T, sibling: T) -> ParentSibling<T> {
+        ParentSibling { parent, sibling }
+    }
+}
+
+macro_rules! impl_tree_stdint {
+    ($t:ty) => {
+        impl TreeIndex for $t {
+            fn root(&self) -> $t {
+                *self - 1
+            }
+
+            /// Panicks if `x` is even in debug, overflows in release.
+            fn left_unchecked(&self) -> Self {
+                *self ^ (0x01 << (self.trailing_ones() - 1))
+            }
+
+            /// Panicks if `x` is even in debug, overflows in release.
+            fn right_unchecked(&self) -> Self {
+                *self ^ (0x03 << (self.trailing_ones() - 1))
+            }
+
+            fn parent_sibling(&self, leaf_count: &Self) -> Option<ParentSibling<Self>> {
+                if self == &leaf_count.root() {
+                    return None;
+                }
+
+                let lvl = self.trailing_ones();
+                let p = (self & !(1 << (lvl + 1))) | (1 << lvl);
+
+                let s = if *self < p {
+                    p.right_unchecked()
+                } else {
+                    p.left_unchecked()
+                };
+
+                Some(ParentSibling::new(p, s))
+            }
+
+            fn is_leaf(&self) -> bool {
+                self & 1 == 0
+            }
+
+            fn is_in_tree(&self, root: &Self) -> bool {
+                *self <= 2 * root
+            }
+
+            #[cfg(any(feature = "secret_tree_access", feature = "private_message"))]
+            fn zero() -> Self {
+                0
+            }
+        }
+    };
+}
+
+impl_tree_stdint!(u32);
+
+#[cfg(test)]
+impl_tree_stdint!(u64);
+
+pub fn leaf_lca_level(x: u32, y: u32) -> u32 {
+    let mut xn = x;
+    let mut yn = y;
+    let mut k = 0;
+
+    while xn != yn {
+        xn >>= 1;
+        yn >>= 1;
+        k += 1;
+    }
+
+    k
+}
+
+pub fn subtree(x: u32) -> (LeafIndex, LeafIndex) {
+    let breadth = 1 << x.trailing_ones();
+    (
+        LeafIndex((x + 1 - breadth) >> 1),
+        LeafIndex(((x + breadth) >> 1) + 1),
+    )
+}
+
+pub struct BfsIterTopDown {
+    level: usize,
+    mask: usize,
+    level_end: usize,
+    ctr: usize,
+}
+
+impl BfsIterTopDown {
+    pub fn new(num_leaves: usize) -> Self {
+        let depth = num_leaves.trailing_zeros() as usize;
+        Self {
+            level: depth + 1,
+            mask: (1 << depth) - 1,
+            level_end: 1,
+            ctr: 0,
+        }
+    }
+}
+
+impl Iterator for BfsIterTopDown {
+    type Item = usize;
+
+    fn next(&mut self) -> Option<Self::Item> {
+        if self.ctr == self.level_end {
+            if self.level == 1 {
+                return None;
+            }
+            self.level_end = (((self.level_end - 1) << 1) | 1) + 1;
+            self.level -= 1;
+            self.ctr = 0;
+            self.mask >>= 1;
+        }
+        let res = Some((self.ctr << self.level) | self.mask);
+        self.ctr += 1;
+        res
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use itertools::Itertools;
+    use serde::{Deserialize, Serialize};
+
+    #[cfg(target_arch = "wasm32")]
+    use wasm_bindgen_test::wasm_bindgen_test as test;
+
+    #[derive(Serialize, Deserialize)]
+    struct TestCase {
+        n_leaves: u32,
+        n_nodes: u32,
+        root: u32,
+        left: Vec<Option<u32>>,
+        right: Vec<Option<u32>>,
+        parent: Vec<Option<u32>>,
+        sibling: Vec<Option<u32>>,
+    }
+
+    pub fn node_width(n: u32) -> u32 {
+        if n == 0 {
+            0
+        } else {
+            2 * (n - 1) + 1
+        }
+    }
+
+    #[test]
+    fn test_bfs_iterator() {
+        let expected = [7, 3, 11, 1, 5, 9, 13, 0, 2, 4, 6, 8, 10, 12, 14];
+        let bfs = BfsIterTopDown::new(8);
+        assert_eq!(bfs.collect::<Vec<_>>(), expected);
+    }
+
+    #[cfg_attr(coverage_nightly, coverage(off))]
+    fn generate_tree_math_test_cases() -> Vec<TestCase> {
+        let mut test_cases = Vec::new();
+
+        for log_n_leaves in 0..8 {
+            let n_leaves = 1 << log_n_leaves;
+            let n_nodes = node_width(n_leaves);
+            let left = (0..n_nodes).map(|x| x.left()).collect::<Vec<_>>();
+            let right = (0..n_nodes).map(|x| x.right()).collect::<Vec<_>>();
+
+            let (parent, sibling) = (0..n_nodes)
+                .map(|x| {
+                    x.parent_sibling(&n_leaves)
+                        .map(|ps| (ps.parent, ps.sibling))
+                        .unzip()
+                })
+                .unzip();
+
+            test_cases.push(TestCase {
+                n_leaves,
+                n_nodes,
+                root: n_leaves.root(),
+                left,
+                right,
+                parent,
+                sibling,
+            })
+        }
+
+        test_cases
+    }
+
+    fn load_test_cases() -> Vec<TestCase> {
+        load_test_case_json!(tree_math, generate_tree_math_test_cases())
+    }
+
+    #[test]
+    fn test_tree_math() {
+        let test_cases = load_test_cases();
+
+        for case in test_cases {
+            assert_eq!(node_width(case.n_leaves), case.n_nodes);
+            assert_eq!(case.n_leaves.root(), case.root);
+
+            for x in 0..case.n_nodes {
+                assert_eq!(x.left(), case.left[x as usize]);
+                assert_eq!(x.right(), case.right[x as usize]);
+
+                let (p, s) = x
+                    .parent_sibling(&case.n_leaves)
+                    .map(|ps| (ps.parent, ps.sibling))
+                    .unzip();
+
+                assert_eq!(p, case.parent[x as usize]);
+                assert_eq!(s, case.sibling[x as usize]);
+            }
+        }
+    }
+
+    #[test]
+    fn test_direct_path() {
+        let expected: Vec<Vec<u32>> = [
+            [0x01, 0x03, 0x07, 0x0f].to_vec(),
+            [0x03, 0x07, 0x0f].to_vec(),
+            [0x01, 0x03, 0x07, 0x0f].to_vec(),
+            [0x07, 0x0f].to_vec(),
+            [0x05, 0x03, 0x07, 0x0f].to_vec(),
+            [0x03, 0x07, 0x0f].to_vec(),
+            [0x05, 0x03, 0x07, 0x0f].to_vec(),
+            [0x0f].to_vec(),
+            [0x09, 0x0b, 0x07, 0x0f].to_vec(),
+            [0x0b, 0x07, 0x0f].to_vec(),
+            [0x09, 0x0b, 0x07, 0x0f].to_vec(),
+            [0x07, 0x0f].to_vec(),
+            [0x0d, 0x0b, 0x07, 0x0f].to_vec(),
+            [0x0b, 0x07, 0x0f].to_vec(),
+            [0x0d, 0x0b, 0x07, 0x0f].to_vec(),
+            [].to_vec(),
+            [0x11, 0x13, 0x17, 0x0f].to_vec(),
+            [0x13, 0x17, 0x0f].to_vec(),
+            [0x11, 0x13, 0x17, 0x0f].to_vec(),
+            [0x17, 0x0f].to_vec(),
+            [0x15, 0x13, 0x17, 0x0f].to_vec(),
+            [0x13, 0x17, 0x0f].to_vec(),
+            [0x15, 0x13, 0x17, 0x0f].to_vec(),
+            [0x0f].to_vec(),
+            [0x19, 0x1b, 0x17, 0x0f].to_vec(),
+            [0x1b, 0x17, 0x0f].to_vec(),
+            [0x19, 0x1b, 0x17, 0x0f].to_vec(),
+            [0x17, 0x0f].to_vec(),
+            [0x1d, 0x1b, 0x17, 0x0f].to_vec(),
+            [0x1b, 0x17, 0x0f].to_vec(),
+            [0x1d, 0x1b, 0x17, 0x0f].to_vec(),
+        ]
+        .to_vec();
+
+        for (i, item) in expected.iter().enumerate() {
+            let path = (i as u32)
+                .direct_copath(&16)
+                .into_iter()
+                .map(|cp| cp.path)
+                .collect_vec();
+
+            assert_eq!(item, &path)
+        }
+    }
+
+    #[test]
+    fn test_copath_path() {
+        let expected: Vec<Vec<u32>> = [
+            [0x02, 0x05, 0x0b, 0x17].to_vec(),
+            [0x05, 0x0b, 0x17].to_vec(),
+            [0x00, 0x05, 0x0b, 0x17].to_vec(),
+            [0x0b, 0x17].to_vec(),
+            [0x06, 0x01, 0x0b, 0x17].to_vec(),
+            [0x01, 0x0b, 0x17].to_vec(),
+            [0x04, 0x01, 0x0b, 0x17].to_vec(),
+            [0x17].to_vec(),
+            [0x0a, 0x0d, 0x03, 0x17].to_vec(),
+            [0x0d, 0x03, 0x17].to_vec(),
+            [0x08, 0x0d, 0x03, 0x17].to_vec(),
+            [0x03, 0x17].to_vec(),
+            [0x0e, 0x09, 0x03, 0x17].to_vec(),
+            [0x09, 0x03, 0x17].to_vec(),
+            [0x0c, 0x09, 0x03, 0x17].to_vec(),
+            [].to_vec(),
+            [0x12, 0x15, 0x1b, 0x07].to_vec(),
+            [0x15, 0x1b, 0x07].to_vec(),
+            [0x10, 0x15, 0x1b, 0x07].to_vec(),
+            [0x1b, 0x07].to_vec(),
+            [0x16, 0x11, 0x1b, 0x07].to_vec(),
+            [0x11, 0x1b, 0x07].to_vec(),
+            [0x14, 0x11, 0x1b, 0x07].to_vec(),
+            [0x07].to_vec(),
+            [0x1a, 0x1d, 0x13, 0x07].to_vec(),
+            [0x1d, 0x13, 0x07].to_vec(),
+            [0x18, 0x1d, 0x13, 0x07].to_vec(),
+            [0x13, 0x07].to_vec(),
+            [0x1e, 0x19, 0x13, 0x07].to_vec(),
+            [0x19, 0x13, 0x07].to_vec(),
+            [0x1c, 0x19, 0x13, 0x07].to_vec(),
+        ]
+        .to_vec();
+
+        for (i, item) in expected.iter().enumerate() {
+            let copath = (i as u32)
+                .direct_copath(&16)
+                .into_iter()
+                .map(|cp| cp.copath)
+                .collect_vec();
+
+            assert_eq!(item, &copath)
+        }
+    }
+}
diff --git a/src/tree_kem/mod.rs b/src/tree_kem/mod.rs
new file mode 100644
index 0000000..430ee16
--- /dev/null
+++ b/src/tree_kem/mod.rs
@@ -0,0 +1,1490 @@
+// 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;
+#[cfg(feature = "std")]
+use core::fmt::Display;
+use itertools::Itertools;
+use mls_rs_codec::{MlsDecode, MlsEncode, MlsSize};
+use mls_rs_core::extension::ExtensionList;
+
+use mls_rs_core::{error::IntoAnyError, identity::IdentityProvider};
+
+#[cfg(feature = "tree_index")]
+use mls_rs_core::identity::SigningIdentity;
+
+use math as tree_math;
+use node::{LeafIndex, NodeIndex, NodeVec};
+
+use self::leaf_node::LeafNode;
+
+use crate::client::MlsError;
+use crate::crypto::{self, CipherSuiteProvider, HpkeSecretKey};
+
+#[cfg(feature = "by_ref_proposal")]
+use crate::group::proposal::{AddProposal, UpdateProposal};
+
+#[cfg(any(test, feature = "by_ref_proposal"))]
+use crate::group::proposal::RemoveProposal;
+
+use crate::group::proposal_filter::ProposalBundle;
+use crate::tree_kem::tree_hash::TreeHashes;
+
+mod capabilities;
+pub(crate) mod hpke_encryption;
+mod lifetime;
+pub(crate) mod math;
+pub mod node;
+pub mod parent_hash;
+pub mod path_secret;
+mod private;
+mod tree_hash;
+pub mod tree_validator;
+pub mod update_path;
+
+pub use capabilities::*;
+pub use lifetime::*;
+pub(crate) use private::*;
+pub use update_path::*;
+
+use tree_index::*;
+
+pub mod kem;
+pub mod leaf_node;
+pub mod leaf_node_validator;
+mod tree_index;
+
+#[cfg(feature = "std")]
+pub(crate) mod tree_utils;
+
+#[cfg(test)]
+mod interop_test_vectors;
+
+#[cfg(feature = "custom_proposal")]
+use crate::group::proposal::ProposalType;
+
+#[derive(Clone, Debug, MlsEncode, MlsDecode, MlsSize, Default)]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+pub struct TreeKemPublic {
+    #[cfg(feature = "tree_index")]
+    #[cfg_attr(feature = "serde", serde(skip))]
+    index: TreeIndex,
+    pub(crate) nodes: NodeVec,
+    tree_hashes: TreeHashes,
+}
+
+impl PartialEq for TreeKemPublic {
+    fn eq(&self, other: &Self) -> bool {
+        self.nodes == other.nodes
+    }
+}
+
+impl TreeKemPublic {
+    pub fn new() -> TreeKemPublic {
+        Default::default()
+    }
+
+    #[cfg_attr(not(feature = "tree_index"), allow(unused))]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(crate) async fn import_node_data<IP>(
+        nodes: NodeVec,
+        identity_provider: &IP,
+        extensions: &ExtensionList,
+    ) -> Result<TreeKemPublic, MlsError>
+    where
+        IP: IdentityProvider,
+    {
+        let mut tree = TreeKemPublic {
+            nodes,
+            ..Default::default()
+        };
+
+        #[cfg(feature = "tree_index")]
+        tree.initialize_index_if_necessary(identity_provider, extensions)
+            .await?;
+
+        Ok(tree)
+    }
+
+    #[cfg(feature = "tree_index")]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(crate) async fn initialize_index_if_necessary<IP: IdentityProvider>(
+        &mut self,
+        identity_provider: &IP,
+        extensions: &ExtensionList,
+    ) -> Result<(), MlsError> {
+        if !self.index.is_initialized() {
+            self.index = TreeIndex::new();
+
+            for (leaf_index, leaf) in self.nodes.non_empty_leaves() {
+                index_insert(
+                    &mut self.index,
+                    leaf,
+                    leaf_index,
+                    identity_provider,
+                    extensions,
+                )
+                .await?;
+            }
+        }
+
+        Ok(())
+    }
+
+    #[cfg(feature = "tree_index")]
+    pub(crate) fn get_leaf_node_with_identity(&self, identity: &[u8]) -> Option<LeafIndex> {
+        self.index.get_leaf_index_with_identity(identity)
+    }
+
+    #[cfg(not(feature = "tree_index"))]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(crate) async fn get_leaf_node_with_identity<I: IdentityProvider>(
+        &self,
+        identity: &[u8],
+        id_provider: &I,
+        extensions: &ExtensionList,
+    ) -> Result<Option<LeafIndex>, MlsError> {
+        for (i, leaf) in self.nodes.non_empty_leaves() {
+            let leaf_id = id_provider
+                .identity(&leaf.signing_identity, extensions)
+                .await
+                .map_err(|e| MlsError::IdentityProviderError(e.into_any_error()))?;
+
+            if leaf_id == identity {
+                return Ok(Some(i));
+            }
+        }
+
+        Ok(None)
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn derive<I: IdentityProvider>(
+        leaf_node: LeafNode,
+        secret_key: HpkeSecretKey,
+        identity_provider: &I,
+        extensions: &ExtensionList,
+    ) -> Result<(TreeKemPublic, TreeKemPrivate), MlsError> {
+        let mut public_tree = TreeKemPublic::new();
+
+        public_tree
+            .add_leaf(leaf_node, identity_provider, extensions, None)
+            .await?;
+
+        let private_tree = TreeKemPrivate::new_self_leaf(LeafIndex(0), secret_key);
+
+        Ok((public_tree, private_tree))
+    }
+
+    pub fn total_leaf_count(&self) -> u32 {
+        self.nodes.total_leaf_count()
+    }
+
+    #[cfg(any(test, all(feature = "custom_proposal", feature = "tree_index")))]
+    pub fn occupied_leaf_count(&self) -> u32 {
+        self.nodes.occupied_leaf_count()
+    }
+
+    pub fn get_leaf_node(&self, index: LeafIndex) -> Result<&LeafNode, MlsError> {
+        self.nodes.borrow_as_leaf(index)
+    }
+
+    pub fn find_leaf_node(&self, leaf_node: &LeafNode) -> Option<LeafIndex> {
+        self.nodes.non_empty_leaves().find_map(
+            |(index, node)| {
+                if node == leaf_node {
+                    Some(index)
+                } else {
+                    None
+                }
+            },
+        )
+    }
+
+    #[cfg(feature = "custom_proposal")]
+    pub fn can_support_proposal(&self, proposal_type: ProposalType) -> bool {
+        #[cfg(feature = "tree_index")]
+        return self.index.count_supporting_proposal(proposal_type) == self.occupied_leaf_count();
+
+        #[cfg(not(feature = "tree_index"))]
+        self.nodes
+            .non_empty_leaves()
+            .all(|(_, l)| l.capabilities.proposals.contains(&proposal_type))
+    }
+
+    #[cfg(test)]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn add_leaves<I: IdentityProvider, CP: CipherSuiteProvider>(
+        &mut self,
+        leaf_nodes: Vec<LeafNode>,
+        id_provider: &I,
+        cipher_suite_provider: &CP,
+    ) -> Result<Vec<LeafIndex>, MlsError> {
+        let mut start = LeafIndex(0);
+        let mut added = vec![];
+
+        for leaf in leaf_nodes.into_iter() {
+            start = self
+                .add_leaf(leaf, id_provider, &Default::default(), Some(start))
+                .await?;
+            added.push(start);
+        }
+
+        self.update_hashes(&added, cipher_suite_provider).await?;
+
+        Ok(added)
+    }
+
+    pub fn non_empty_leaves(&self) -> impl Iterator<Item = (LeafIndex, &LeafNode)> + '_ {
+        self.nodes.non_empty_leaves()
+    }
+
+    #[cfg(feature = "prior_epoch")]
+    pub fn leaves(&self) -> impl Iterator<Item = Option<&LeafNode>> + '_ {
+        self.nodes.leaves()
+    }
+
+    pub(crate) fn update_node(
+        &mut self,
+        pub_key: crypto::HpkePublicKey,
+        index: NodeIndex,
+    ) -> Result<(), MlsError> {
+        self.nodes
+            .borrow_or_fill_node_as_parent(index, &pub_key)
+            .map(|p| {
+                p.public_key = pub_key;
+                p.unmerged_leaves = vec![];
+            })
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(crate) async fn apply_update_path<IP, CP>(
+        &mut self,
+        sender: LeafIndex,
+        update_path: &ValidatedUpdatePath,
+        extensions: &ExtensionList,
+        identity_provider: IP,
+        cipher_suite_provider: &CP,
+    ) -> Result<(), MlsError>
+    where
+        IP: IdentityProvider,
+        CP: CipherSuiteProvider,
+    {
+        // Install the new leaf node
+        let existing_leaf = self.nodes.borrow_as_leaf_mut(sender)?;
+
+        #[cfg(feature = "tree_index")]
+        let original_leaf_node = existing_leaf.clone();
+
+        #[cfg(feature = "tree_index")]
+        let original_identity = identity_provider
+            .identity(&original_leaf_node.signing_identity, extensions)
+            .await
+            .map_err(|e| MlsError::IdentityProviderError(e.into_any_error()))?;
+
+        *existing_leaf = update_path.leaf_node.clone();
+
+        // Update the rest of the nodes on the direct path
+        let path = self.nodes.direct_copath(sender);
+
+        for (node, pn) in update_path.nodes.iter().zip(path) {
+            node.as_ref()
+                .map(|n| self.update_node(n.public_key.clone(), pn.path))
+                .transpose()?;
+        }
+
+        #[cfg(feature = "tree_index")]
+        self.index.remove(&original_leaf_node, &original_identity);
+
+        index_insert(
+            #[cfg(feature = "tree_index")]
+            &mut self.index,
+            #[cfg(not(feature = "tree_index"))]
+            &self.nodes,
+            &update_path.leaf_node,
+            sender,
+            &identity_provider,
+            extensions,
+        )
+        .await?;
+
+        // Verify the parent hash of the new sender leaf node and update the parent hash values
+        // in the local tree
+        self.update_parent_hashes(sender, true, cipher_suite_provider)
+            .await?;
+
+        Ok(())
+    }
+
+    fn update_unmerged(&mut self, index: LeafIndex) -> Result<(), MlsError> {
+        // For a given leaf index, find parent nodes and add the leaf to the unmerged leaf
+        self.nodes.direct_copath(index).into_iter().for_each(|i| {
+            if let Ok(p) = self.nodes.borrow_as_parent_mut(i.path) {
+                p.unmerged_leaves.push(index)
+            }
+        });
+
+        Ok(())
+    }
+
+    #[cfg(feature = "by_ref_proposal")]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn batch_edit<I, CP>(
+        &mut self,
+        proposal_bundle: &mut ProposalBundle,
+        extensions: &ExtensionList,
+        id_provider: &I,
+        cipher_suite_provider: &CP,
+        filter: bool,
+    ) -> Result<Vec<LeafIndex>, MlsError>
+    where
+        I: IdentityProvider,
+        CP: CipherSuiteProvider,
+    {
+        // Apply removes (they commute with updates because they don't touch the same leaves)
+        for i in (0..proposal_bundle.remove_proposals().len()).rev() {
+            let index = proposal_bundle.remove_proposals()[i].proposal.to_remove;
+            let res = self.nodes.blank_leaf_node(index);
+
+            if res.is_ok() {
+                // This shouldn't fail if `blank_leaf_node` succedded.
+                self.nodes.blank_direct_path(index)?;
+            }
+
+            #[cfg(feature = "tree_index")]
+            if let Ok(old_leaf) = &res {
+                // If this fails, it's not because the proposal is bad.
+                let identity =
+                    identity(&old_leaf.signing_identity, id_provider, extensions).await?;
+
+                self.index.remove(old_leaf, &identity);
+            }
+
+            if proposal_bundle.remove_proposals()[i].is_by_value() || !filter {
+                res?;
+            } else if res.is_err() {
+                proposal_bundle.remove::<RemoveProposal>(i);
+            }
+        }
+
+        // Remove from the tree old leaves from updates
+        let mut partial_updates = vec![];
+        let senders = proposal_bundle.update_senders.iter().copied();
+
+        for (i, (p, index)) in proposal_bundle.updates.iter().zip(senders).enumerate() {
+            let new_leaf = p.proposal.leaf_node.clone();
+
+            match self.nodes.blank_leaf_node(index) {
+                Ok(old_leaf) => {
+                    #[cfg(feature = "tree_index")]
+                    let old_id =
+                        identity(&old_leaf.signing_identity, id_provider, extensions).await?;
+
+                    #[cfg(feature = "tree_index")]
+                    self.index.remove(&old_leaf, &old_id);
+
+                    partial_updates.push((index, old_leaf, new_leaf, i));
+                }
+                _ => {
+                    if !filter || !p.is_by_reference() {
+                        return Err(MlsError::UpdatingNonExistingMember);
+                    }
+                }
+            }
+        }
+
+        #[cfg(feature = "tree_index")]
+        let index_clone = self.index.clone();
+
+        let mut removed_leaves = vec![];
+        let mut updated_indices = vec![];
+        let mut bad_indices = vec![];
+
+        // Apply updates one by one. If there's an update which we can't apply or revert, we revert
+        // all updates.
+        for (index, old_leaf, new_leaf, i) in partial_updates.into_iter() {
+            #[cfg(feature = "tree_index")]
+            let res =
+                index_insert(&mut self.index, &new_leaf, index, id_provider, extensions).await;
+
+            #[cfg(not(feature = "tree_index"))]
+            let res = index_insert(&self.nodes, &new_leaf, index, id_provider, extensions).await;
+
+            let err = res.is_err();
+
+            if !filter {
+                res?;
+            }
+
+            if !err {
+                self.nodes.insert_leaf(index, new_leaf);
+                removed_leaves.push(old_leaf);
+                updated_indices.push(index);
+            } else {
+                #[cfg(feature = "tree_index")]
+                let res =
+                    index_insert(&mut self.index, &old_leaf, index, id_provider, extensions).await;
+
+                #[cfg(not(feature = "tree_index"))]
+                let res =
+                    index_insert(&self.nodes, &old_leaf, index, id_provider, extensions).await;
+
+                if res.is_ok() {
+                    self.nodes.insert_leaf(index, old_leaf);
+                    bad_indices.push(i);
+                } else {
+                    // Revert all updates and stop. We're already in the "filter" case, so we don't throw an error.
+                    #[cfg(feature = "tree_index")]
+                    {
+                        self.index = index_clone;
+                    }
+
+                    removed_leaves
+                        .into_iter()
+                        .zip(updated_indices.iter())
+                        .for_each(|(leaf, index)| self.nodes.insert_leaf(*index, leaf));
+
+                    updated_indices = vec![];
+                    break;
+                }
+            }
+        }
+
+        // If we managed to update something, blank direct paths
+        updated_indices
+            .iter()
+            .try_for_each(|index| self.nodes.blank_direct_path(*index).map(|_| ()))?;
+
+        // Remove rejected updates from applied proposals
+        if updated_indices.is_empty() {
+            // This takes care of the "revert all" scenario
+            proposal_bundle.updates = vec![];
+        } else {
+            for i in bad_indices.into_iter().rev() {
+                proposal_bundle.remove::<UpdateProposal>(i);
+                proposal_bundle.update_senders.remove(i);
+            }
+        }
+
+        // Apply adds
+        let mut start = LeafIndex(0);
+        let mut added = vec![];
+        let mut bad_indexes = vec![];
+
+        for i in 0..proposal_bundle.additions.len() {
+            let leaf = proposal_bundle.additions[i]
+                .proposal
+                .key_package
+                .leaf_node
+                .clone();
+
+            let res = self
+                .add_leaf(leaf, id_provider, extensions, Some(start))
+                .await;
+
+            if let Ok(index) = res {
+                start = index;
+                added.push(start);
+            } else if proposal_bundle.additions[i].is_by_value() || !filter {
+                res?;
+            } else {
+                bad_indexes.push(i);
+            }
+        }
+
+        for i in bad_indexes.into_iter().rev() {
+            proposal_bundle.remove::<AddProposal>(i);
+        }
+
+        self.nodes.trim();
+
+        let updated_leaves = proposal_bundle
+            .remove_proposals()
+            .iter()
+            .map(|p| p.proposal.to_remove)
+            .chain(updated_indices)
+            .chain(added.iter().copied())
+            .collect_vec();
+
+        self.update_hashes(&updated_leaves, cipher_suite_provider)
+            .await?;
+
+        Ok(added)
+    }
+
+    #[cfg(not(feature = "by_ref_proposal"))]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn batch_edit_lite<I, CP>(
+        &mut self,
+        proposal_bundle: &ProposalBundle,
+        extensions: &ExtensionList,
+        id_provider: &I,
+        cipher_suite_provider: &CP,
+    ) -> Result<Vec<LeafIndex>, MlsError>
+    where
+        I: IdentityProvider,
+        CP: CipherSuiteProvider,
+    {
+        // Apply removes
+        for p in &proposal_bundle.removals {
+            let index = p.proposal.to_remove;
+
+            #[cfg(feature = "tree_index")]
+            {
+                // If this fails, it's not because the proposal is bad.
+                let old_leaf = self.nodes.blank_leaf_node(index)?;
+
+                let identity =
+                    identity(&old_leaf.signing_identity, id_provider, extensions).await?;
+
+                self.index.remove(&old_leaf, &identity);
+            }
+
+            #[cfg(not(feature = "tree_index"))]
+            self.nodes.blank_leaf_node(index)?;
+
+            self.nodes.blank_direct_path(index)?;
+        }
+
+        // Apply adds
+        let mut start = LeafIndex(0);
+        let mut added = vec![];
+
+        for p in &proposal_bundle.additions {
+            let leaf = p.proposal.key_package.leaf_node.clone();
+            start = self
+                .add_leaf(leaf, id_provider, extensions, Some(start))
+                .await?;
+            added.push(start);
+        }
+
+        self.nodes.trim();
+
+        let updated_leaves = proposal_bundle
+            .remove_proposals()
+            .iter()
+            .map(|p| p.proposal.to_remove)
+            .chain(added.iter().copied())
+            .collect_vec();
+
+        self.update_hashes(&updated_leaves, cipher_suite_provider)
+            .await?;
+
+        Ok(added)
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(crate) async fn add_leaf<I: IdentityProvider>(
+        &mut self,
+        leaf: LeafNode,
+        id_provider: &I,
+        extensions: &ExtensionList,
+        start: Option<LeafIndex>,
+    ) -> Result<LeafIndex, MlsError> {
+        let index = self.nodes.next_empty_leaf(start.unwrap_or(LeafIndex(0)));
+
+        #[cfg(feature = "tree_index")]
+        index_insert(&mut self.index, &leaf, index, id_provider, extensions).await?;
+
+        #[cfg(not(feature = "tree_index"))]
+        index_insert(&self.nodes, &leaf, index, id_provider, extensions).await?;
+
+        self.nodes.insert_leaf(index, leaf);
+        self.update_unmerged(index)?;
+
+        Ok(index)
+    }
+}
+
+#[cfg(feature = "tree_index")]
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+async fn identity<I: IdentityProvider>(
+    signing_id: &SigningIdentity,
+    provider: &I,
+    extensions: &ExtensionList,
+) -> Result<Vec<u8>, MlsError> {
+    provider
+        .identity(signing_id, extensions)
+        .await
+        .map_err(|e| MlsError::IdentityProviderError(e.into_any_error()))
+}
+
+#[cfg(feature = "std")]
+impl Display for TreeKemPublic {
+    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
+        write!(f, "{}", tree_utils::build_ascii_tree(&self.nodes))
+    }
+}
+
+#[cfg(test)]
+use crate::group::{proposal::Proposal, proposal_filter::ProposalSource, Sender};
+
+#[cfg(test)]
+impl TreeKemPublic {
+    #[cfg(feature = "by_ref_proposal")]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn update_leaf<I, CP>(
+        &mut self,
+        leaf_index: u32,
+        leaf_node: LeafNode,
+        identity_provider: &I,
+        cipher_suite_provider: &CP,
+    ) -> Result<(), MlsError>
+    where
+        I: IdentityProvider,
+        CP: CipherSuiteProvider,
+    {
+        let p = Proposal::Update(UpdateProposal { leaf_node });
+
+        let mut bundle = ProposalBundle::default();
+        bundle.add(p, Sender::Member(leaf_index), ProposalSource::ByValue);
+        bundle.update_senders = vec![LeafIndex(leaf_index)];
+
+        self.batch_edit(
+            &mut bundle,
+            &Default::default(),
+            identity_provider,
+            cipher_suite_provider,
+            true,
+        )
+        .await?;
+
+        Ok(())
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn remove_leaves<I, CP>(
+        &mut self,
+        indexes: Vec<LeafIndex>,
+        identity_provider: &I,
+        cipher_suite_provider: &CP,
+    ) -> Result<Vec<(LeafIndex, LeafNode)>, MlsError>
+    where
+        I: IdentityProvider,
+        CP: CipherSuiteProvider,
+    {
+        let old_tree = self.clone();
+
+        let proposals = indexes
+            .iter()
+            .copied()
+            .map(|to_remove| Proposal::Remove(RemoveProposal { to_remove }));
+
+        let mut bundle = ProposalBundle::default();
+
+        for p in proposals {
+            bundle.add(p, Sender::Member(0), ProposalSource::ByValue);
+        }
+
+        #[cfg(feature = "by_ref_proposal")]
+        self.batch_edit(
+            &mut bundle,
+            &Default::default(),
+            identity_provider,
+            cipher_suite_provider,
+            true,
+        )
+        .await?;
+
+        #[cfg(not(feature = "by_ref_proposal"))]
+        self.batch_edit_lite(
+            &bundle,
+            &Default::default(),
+            identity_provider,
+            cipher_suite_provider,
+        )
+        .await?;
+
+        bundle
+            .removals
+            .iter()
+            .map(|p| {
+                let index = p.proposal.to_remove;
+                let leaf = old_tree.get_leaf_node(index)?.clone();
+                Ok((index, leaf))
+            })
+            .collect()
+    }
+
+    pub fn get_leaf_nodes(&self) -> Vec<&LeafNode> {
+        self.nodes.non_empty_leaves().map(|(_, l)| l).collect()
+    }
+}
+
+#[cfg(test)]
+pub(crate) mod test_utils {
+    use crate::crypto::test_utils::TestCryptoProvider;
+    use crate::signer::Signable;
+    use alloc::vec::Vec;
+    use alloc::{format, vec};
+    use mls_rs_core::crypto::CipherSuiteProvider;
+    use mls_rs_core::group::Capabilities;
+    use mls_rs_core::identity::BasicCredential;
+
+    use crate::identity::test_utils::get_test_signing_identity;
+    use crate::{
+        cipher_suite::CipherSuite,
+        crypto::{HpkeSecretKey, SignatureSecretKey},
+        identity::basic::BasicIdentityProvider,
+        tree_kem::leaf_node::test_utils::get_basic_test_node_sig_key,
+    };
+
+    use super::leaf_node::{ConfigProperties, LeafNodeSigningContext};
+    use super::node::LeafIndex;
+    use super::Lifetime;
+    use super::{
+        leaf_node::{test_utils::get_basic_test_node, LeafNode},
+        TreeKemPrivate, TreeKemPublic,
+    };
+
+    #[derive(Debug)]
+    pub(crate) struct TestTree {
+        pub public: TreeKemPublic,
+        pub private: TreeKemPrivate,
+        pub creator_leaf: LeafNode,
+        pub creator_signing_key: SignatureSecretKey,
+        pub creator_hpke_secret: HpkeSecretKey,
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(crate) async fn get_test_tree(cipher_suite: CipherSuite) -> TestTree {
+        let (creator_leaf, creator_hpke_secret, creator_signing_key) =
+            get_basic_test_node_sig_key(cipher_suite, "creator").await;
+
+        let (test_public, test_private) = TreeKemPublic::derive(
+            creator_leaf.clone(),
+            creator_hpke_secret.clone(),
+            &BasicIdentityProvider,
+            &Default::default(),
+        )
+        .await
+        .unwrap();
+
+        TestTree {
+            public: test_public,
+            private: test_private,
+            creator_leaf,
+            creator_signing_key,
+            creator_hpke_secret,
+        }
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn get_test_leaf_nodes(cipher_suite: CipherSuite) -> Vec<LeafNode> {
+        [
+            get_basic_test_node(cipher_suite, "A").await,
+            get_basic_test_node(cipher_suite, "B").await,
+            get_basic_test_node(cipher_suite, "C").await,
+        ]
+        .to_vec()
+    }
+
+    impl TreeKemPublic {
+        #[cfg(feature = "tree_index")]
+        pub fn equal_internals(&self, other: &TreeKemPublic) -> bool {
+            self.tree_hashes == other.tree_hashes && self.index == other.index
+        }
+    }
+
+    #[derive(Debug, Clone)]
+    pub struct TreeWithSigners {
+        pub tree: TreeKemPublic,
+        pub signers: Vec<Option<SignatureSecretKey>>,
+        pub group_id: Vec<u8>,
+    }
+
+    impl TreeWithSigners {
+        #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+        pub async fn make_full_tree<P: CipherSuiteProvider>(
+            n_leaves: u32,
+            cs: &P,
+        ) -> TreeWithSigners {
+            let mut tree = TreeWithSigners {
+                tree: TreeKemPublic::new(),
+                signers: vec![],
+                group_id: cs.random_bytes_vec(cs.kdf_extract_size()).unwrap(),
+            };
+
+            tree.add_member("Alice", cs).await;
+
+            // A adds B, B adds C, C adds D etc.
+            for i in 1..n_leaves {
+                tree.add_member(&format!("Alice{i}"), cs).await;
+                tree.update_committer_path(i - 1, cs).await;
+            }
+
+            tree
+        }
+
+        #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+        pub async fn add_member<P: CipherSuiteProvider>(&mut self, name: &str, cs: &P) {
+            let (leaf, signer) = make_leaf(name, cs).await;
+            let index = self.tree.nodes.next_empty_leaf(LeafIndex(0));
+            self.tree.nodes.insert_leaf(index, leaf);
+            self.tree.update_unmerged(index).unwrap();
+            let index = *index as usize;
+
+            match self.signers.len() {
+                l if l == index => self.signers.push(Some(signer)),
+                l if l > index => self.signers[index] = Some(signer),
+                _ => panic!("signer tree size mismatch"),
+            }
+        }
+
+        #[cfg(feature = "rfc_compliant")]
+        #[cfg_attr(coverage_nightly, coverage(off))]
+        pub fn remove_member(&mut self, member: u32) {
+            self.tree
+                .nodes
+                .blank_direct_path(LeafIndex(member))
+                .unwrap();
+
+            self.tree.nodes.blank_leaf_node(LeafIndex(member)).unwrap();
+
+            *self
+                .signers
+                .get_mut(member as usize)
+                .expect("signer tree size mismatch") = None;
+        }
+
+        #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+        pub async fn update_committer_path<P: CipherSuiteProvider>(
+            &mut self,
+            committer: u32,
+            cs: &P,
+        ) {
+            let committer = LeafIndex(committer);
+
+            let path = self.tree.nodes.direct_copath(committer);
+            let filtered = self.tree.nodes.filtered(committer).unwrap();
+
+            for (n, f) in path.into_iter().zip(filtered) {
+                if !f {
+                    self.tree
+                        .update_node(cs.kem_generate().await.unwrap().1, n.path)
+                        .unwrap();
+                }
+            }
+
+            self.tree.tree_hashes.current = vec![];
+            self.tree.tree_hash(cs).await.unwrap();
+
+            self.tree
+                .update_parent_hashes(committer, false, cs)
+                .await
+                .unwrap();
+
+            self.tree.tree_hashes.current = vec![];
+            self.tree.tree_hash(cs).await.unwrap();
+
+            let context = LeafNodeSigningContext {
+                group_id: Some(&self.group_id),
+                leaf_index: Some(*committer),
+            };
+
+            let signer = self.signers[*committer as usize].as_ref().unwrap();
+
+            self.tree
+                .nodes
+                .borrow_as_leaf_mut(committer)
+                .unwrap()
+                .sign(cs, signer, &context)
+                .await
+                .unwrap();
+
+            self.tree.tree_hashes.current = vec![];
+            self.tree.tree_hash(cs).await.unwrap();
+        }
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn make_leaf<P: CipherSuiteProvider>(
+        name: &str,
+        cs: &P,
+    ) -> (LeafNode, SignatureSecretKey) {
+        let (signing_identity, signature_key) =
+            get_test_signing_identity(cs.cipher_suite(), name.as_bytes()).await;
+
+        let capabilities = Capabilities {
+            credentials: vec![BasicCredential::credential_type()],
+            cipher_suites: TestCryptoProvider::all_supported_cipher_suites(),
+            ..Default::default()
+        };
+
+        let properties = ConfigProperties {
+            capabilities,
+            extensions: Default::default(),
+        };
+
+        let (leaf, _) = LeafNode::generate(
+            cs,
+            properties,
+            signing_identity,
+            &signature_key,
+            Lifetime::years(1).unwrap(),
+        )
+        .await
+        .unwrap();
+
+        (leaf, signature_key)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use crate::client::test_utils::TEST_CIPHER_SUITE;
+    use crate::crypto::test_utils::{test_cipher_suite_provider, TestCryptoProvider};
+
+    #[cfg(feature = "custom_proposal")]
+    use crate::group::proposal::ProposalType;
+
+    use crate::identity::basic::BasicIdentityProvider;
+    use crate::tree_kem::leaf_node::LeafNode;
+    use crate::tree_kem::node::{LeafIndex, Node, NodeIndex, NodeTypeResolver, Parent};
+    use crate::tree_kem::parent_hash::ParentHash;
+    use crate::tree_kem::test_utils::{get_test_leaf_nodes, get_test_tree};
+    use crate::tree_kem::{MlsError, TreeKemPublic};
+    use alloc::borrow::ToOwned;
+    use alloc::vec;
+    use alloc::vec::Vec;
+    use assert_matches::assert_matches;
+
+    #[cfg(feature = "by_ref_proposal")]
+    use alloc::boxed::Box;
+
+    #[cfg(feature = "by_ref_proposal")]
+    use crate::{
+        client::test_utils::TEST_PROTOCOL_VERSION,
+        group::{
+            proposal::{Proposal, RemoveProposal, UpdateProposal},
+            proposal_filter::{ProposalBundle, ProposalSource},
+            proposal_ref::ProposalRef,
+            Sender,
+        },
+        key_package::test_utils::test_key_package,
+    };
+
+    #[cfg(any(feature = "by_ref_proposal", feature = "custo_proposal"))]
+    use crate::tree_kem::leaf_node::test_utils::get_basic_test_node;
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_derive() {
+        for cipher_suite in TestCryptoProvider::all_supported_cipher_suites() {
+            let test_tree = get_test_tree(cipher_suite).await;
+
+            assert_eq!(
+                test_tree.public.nodes[0],
+                Some(Node::Leaf(test_tree.creator_leaf.clone()))
+            );
+
+            assert_eq!(test_tree.private.self_index, LeafIndex(0));
+
+            assert_eq!(
+                test_tree.private.secret_keys[0],
+                Some(test_tree.creator_hpke_secret)
+            );
+        }
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_import_export() {
+        let cipher_suite_provider = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+        let mut test_tree = get_test_tree(TEST_CIPHER_SUITE).await;
+
+        let additional_key_packages = get_test_leaf_nodes(TEST_CIPHER_SUITE).await;
+
+        test_tree
+            .public
+            .add_leaves(
+                additional_key_packages,
+                &BasicIdentityProvider,
+                &cipher_suite_provider,
+            )
+            .await
+            .unwrap();
+
+        let imported = TreeKemPublic::import_node_data(
+            test_tree.public.nodes.clone(),
+            &BasicIdentityProvider,
+            &Default::default(),
+        )
+        .await
+        .unwrap();
+
+        assert_eq!(test_tree.public.nodes, imported.nodes);
+
+        #[cfg(feature = "tree_index")]
+        assert_eq!(test_tree.public.index, imported.index);
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_add_leaf() {
+        let cipher_suite_provider = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+        let mut tree = TreeKemPublic::new();
+
+        let leaf_nodes = get_test_leaf_nodes(TEST_CIPHER_SUITE).await;
+
+        let res = tree
+            .add_leaves(
+                leaf_nodes.clone(),
+                &BasicIdentityProvider,
+                &cipher_suite_provider,
+            )
+            .await
+            .unwrap();
+
+        // The leaf count should be equal to the number of packages we added
+        assert_eq!(res.len(), leaf_nodes.len());
+        assert_eq!(tree.occupied_leaf_count(), leaf_nodes.len() as u32);
+
+        // Each added package should be at the proper index and searchable in the tree
+        res.into_iter().zip(leaf_nodes.clone()).for_each(|(r, kp)| {
+            assert_eq!(tree.get_leaf_node(r).unwrap(), &kp);
+        });
+
+        // Verify the underlying state
+        #[cfg(feature = "tree_index")]
+        assert_eq!(tree.index.len(), tree.occupied_leaf_count() as usize);
+
+        assert_eq!(tree.nodes.len(), 5);
+        assert_eq!(tree.nodes[0], leaf_nodes[0].clone().into());
+        assert_eq!(tree.nodes[1], None);
+        assert_eq!(tree.nodes[2], leaf_nodes[1].clone().into());
+        assert_eq!(tree.nodes[3], None);
+        assert_eq!(tree.nodes[4], leaf_nodes[2].clone().into());
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_get_key_packages() {
+        let cipher_suite_provider = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+        let mut tree = TreeKemPublic::new();
+
+        let key_packages = get_test_leaf_nodes(TEST_CIPHER_SUITE).await;
+
+        tree.add_leaves(key_packages, &BasicIdentityProvider, &cipher_suite_provider)
+            .await
+            .unwrap();
+
+        let key_packages = tree.get_leaf_nodes();
+        assert_eq!(key_packages, key_packages.to_owned());
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_add_leaf_duplicate() {
+        let cipher_suite_provider = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+        let mut tree = TreeKemPublic::new();
+
+        let key_packages = get_test_leaf_nodes(TEST_CIPHER_SUITE).await;
+
+        tree.add_leaves(
+            key_packages.clone(),
+            &BasicIdentityProvider,
+            &cipher_suite_provider,
+        )
+        .await
+        .unwrap();
+
+        let res = tree
+            .add_leaves(key_packages, &BasicIdentityProvider, &cipher_suite_provider)
+            .await;
+
+        assert_matches!(res, Err(MlsError::DuplicateLeafData(_)));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_add_leaf_empty_leaf() {
+        let cipher_suite_provider = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+        let mut tree = get_test_tree(TEST_CIPHER_SUITE).await.public;
+        let key_packages = get_test_leaf_nodes(TEST_CIPHER_SUITE).await;
+
+        tree.add_leaves(
+            [key_packages[0].clone()].to_vec(),
+            &BasicIdentityProvider,
+            &cipher_suite_provider,
+        )
+        .await
+        .unwrap();
+
+        tree.nodes[0] = None; // Set the original first node to none
+                              //
+        tree.add_leaves(
+            [key_packages[1].clone()].to_vec(),
+            &BasicIdentityProvider,
+            &cipher_suite_provider,
+        )
+        .await
+        .unwrap();
+
+        assert_eq!(tree.nodes[0], key_packages[1].clone().into());
+        assert_eq!(tree.nodes[1], None);
+        assert_eq!(tree.nodes[2], key_packages[0].clone().into());
+        assert_eq!(tree.nodes.len(), 3)
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_add_leaf_unmerged() {
+        let cipher_suite_provider = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+        let mut tree = get_test_tree(TEST_CIPHER_SUITE).await.public;
+        let key_packages = get_test_leaf_nodes(TEST_CIPHER_SUITE).await;
+
+        tree.add_leaves(
+            [key_packages[0].clone(), key_packages[1].clone()].to_vec(),
+            &BasicIdentityProvider,
+            &cipher_suite_provider,
+        )
+        .await
+        .unwrap();
+
+        tree.nodes[3] = Parent {
+            public_key: vec![].into(),
+            parent_hash: ParentHash::empty(),
+            unmerged_leaves: vec![],
+        }
+        .into();
+
+        tree.add_leaves(
+            [key_packages[2].clone()].to_vec(),
+            &BasicIdentityProvider,
+            &cipher_suite_provider,
+        )
+        .await
+        .unwrap();
+
+        assert_eq!(
+            tree.nodes[3].as_parent().unwrap().unmerged_leaves,
+            vec![LeafIndex(3)]
+        )
+    }
+
+    #[cfg(feature = "by_ref_proposal")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_update_leaf() {
+        let cipher_suite_provider = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+        // Create a tree
+        let mut tree = get_test_tree(TEST_CIPHER_SUITE).await.public;
+
+        let key_packages = get_test_leaf_nodes(TEST_CIPHER_SUITE).await;
+
+        tree.add_leaves(key_packages, &BasicIdentityProvider, &cipher_suite_provider)
+            .await
+            .unwrap();
+
+        // Add in parent nodes so we can detect them clearing after update
+        tree.nodes.direct_copath(LeafIndex(0)).iter().for_each(|n| {
+            tree.nodes
+                .borrow_or_fill_node_as_parent(n.path, &b"pub_key".to_vec().into())
+                .unwrap();
+        });
+
+        let original_size = tree.occupied_leaf_count();
+        let original_leaf_index = LeafIndex(1);
+
+        let updated_leaf = get_basic_test_node(TEST_CIPHER_SUITE, "A").await;
+
+        tree.update_leaf(
+            *original_leaf_index,
+            updated_leaf.clone(),
+            &BasicIdentityProvider,
+            &cipher_suite_provider,
+        )
+        .await
+        .unwrap();
+
+        // The tree should not have grown due to an update
+        assert_eq!(tree.occupied_leaf_count(), original_size);
+
+        // The cache of tree package indexes should not have grown
+        #[cfg(feature = "tree_index")]
+        assert_eq!(tree.index.len() as u32, tree.occupied_leaf_count());
+
+        // The key package should be updated in the tree
+        assert_eq!(
+            tree.get_leaf_node(original_leaf_index).unwrap(),
+            &updated_leaf
+        );
+
+        // Verify that the direct path has been cleared
+        tree.nodes.direct_copath(LeafIndex(0)).iter().for_each(|n| {
+            assert!(tree.nodes[n.path as usize].is_none());
+        });
+    }
+
+    #[cfg(feature = "by_ref_proposal")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_update_leaf_not_found() {
+        let cipher_suite_provider = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+
+        // Create a tree
+        let mut tree = get_test_tree(TEST_CIPHER_SUITE).await.public;
+
+        let key_packages = get_test_leaf_nodes(TEST_CIPHER_SUITE).await;
+
+        tree.add_leaves(key_packages, &BasicIdentityProvider, &cipher_suite_provider)
+            .await
+            .unwrap();
+
+        let new_key_package = get_basic_test_node(TEST_CIPHER_SUITE, "new").await;
+
+        let res = tree
+            .update_leaf(
+                128,
+                new_key_package,
+                &BasicIdentityProvider,
+                &cipher_suite_provider,
+            )
+            .await;
+
+        assert_matches!(res, Err(MlsError::UpdatingNonExistingMember));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_remove_leaf() {
+        let cipher_suite_provider = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+
+        // Create a tree
+        let mut tree = get_test_tree(TEST_CIPHER_SUITE).await.public;
+        let key_packages = get_test_leaf_nodes(TEST_CIPHER_SUITE).await;
+
+        let indexes = tree
+            .add_leaves(
+                key_packages.clone(),
+                &BasicIdentityProvider,
+                &cipher_suite_provider,
+            )
+            .await
+            .unwrap();
+
+        let original_leaf_count = tree.occupied_leaf_count();
+
+        // Remove two leaves from the tree
+        let expected_result: Vec<(LeafIndex, LeafNode)> =
+            indexes.clone().into_iter().zip(key_packages).collect();
+
+        let res = tree
+            .remove_leaves(
+                indexes.clone(),
+                &BasicIdentityProvider,
+                &cipher_suite_provider,
+            )
+            .await
+            .unwrap();
+
+        // The order may change
+        assert!(res.iter().all(|x| expected_result.contains(x)));
+        assert!(expected_result.iter().all(|x| res.contains(x)));
+
+        // The leaves should be removed from the tree
+        assert_eq!(
+            tree.occupied_leaf_count(),
+            original_leaf_count - indexes.len() as u32
+        );
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_remove_leaf_middle() {
+        let cipher_suite_provider = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+
+        // Create a tree
+        let mut tree = get_test_tree(TEST_CIPHER_SUITE).await.public;
+        let leaf_nodes = get_test_leaf_nodes(TEST_CIPHER_SUITE).await;
+
+        let to_remove = tree
+            .add_leaves(
+                leaf_nodes.clone(),
+                &BasicIdentityProvider,
+                &cipher_suite_provider,
+            )
+            .await
+            .unwrap()[0];
+
+        let original_leaf_count = tree.occupied_leaf_count();
+
+        let res = tree
+            .remove_leaves(
+                vec![to_remove],
+                &BasicIdentityProvider,
+                &cipher_suite_provider,
+            )
+            .await
+            .unwrap();
+
+        assert_eq!(res, vec![(to_remove, leaf_nodes[0].clone())]);
+
+        // The leaf count should have been reduced by 1
+        assert_eq!(tree.occupied_leaf_count(), original_leaf_count - 1);
+
+        // There should be a blank in the tree
+        assert_eq!(
+            tree.nodes.get(NodeIndex::from(to_remove) as usize).unwrap(),
+            &None
+        );
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_create_blanks() {
+        let cipher_suite_provider = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+
+        // Create a tree
+        let mut tree = get_test_tree(TEST_CIPHER_SUITE).await.public;
+
+        let key_packages = get_test_leaf_nodes(TEST_CIPHER_SUITE).await;
+
+        tree.add_leaves(key_packages, &BasicIdentityProvider, &cipher_suite_provider)
+            .await
+            .unwrap();
+
+        let original_leaf_count = tree.occupied_leaf_count();
+
+        let to_remove = vec![LeafIndex(2)];
+
+        // Remove the leaf from the tree
+        tree.remove_leaves(to_remove, &BasicIdentityProvider, &cipher_suite_provider)
+            .await
+            .unwrap();
+
+        // The occupied leaf count should have been reduced by 1
+        assert_eq!(tree.occupied_leaf_count(), original_leaf_count - 1);
+
+        // The total leaf count should remain unchanged
+        assert_eq!(tree.total_leaf_count(), original_leaf_count);
+
+        // The location of key_packages[1] should now be blank
+        let removed_location = tree
+            .nodes
+            .get(NodeIndex::from(LeafIndex(2)) as usize)
+            .unwrap();
+
+        assert_eq!(removed_location, &None);
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_remove_leaf_failure() {
+        let cipher_suite_provider = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+
+        // Create a tree
+        let mut tree = get_test_tree(TEST_CIPHER_SUITE).await.public;
+
+        let res = tree
+            .remove_leaves(
+                vec![LeafIndex(128)],
+                &BasicIdentityProvider,
+                &cipher_suite_provider,
+            )
+            .await;
+
+        assert_matches!(res, Err(MlsError::InvalidNodeIndex(256)));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_find_leaf_node() {
+        let cipher_suite_provider = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+        // Create a tree
+        let mut tree = get_test_tree(TEST_CIPHER_SUITE).await.public;
+
+        let leaf_nodes = get_test_leaf_nodes(TEST_CIPHER_SUITE).await;
+
+        tree.add_leaves(
+            leaf_nodes.clone(),
+            &BasicIdentityProvider,
+            &cipher_suite_provider,
+        )
+        .await
+        .unwrap();
+
+        // Find each node
+        for (i, leaf_node) in leaf_nodes.iter().enumerate() {
+            let expected_index = LeafIndex(i as u32 + 1);
+            assert_eq!(tree.find_leaf_node(leaf_node), Some(expected_index));
+        }
+    }
+
+    // TODO add test for the lite version
+    #[cfg(feature = "by_ref_proposal")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn batch_edit_works() {
+        let cipher_suite_provider = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+
+        let mut tree = get_test_tree(TEST_CIPHER_SUITE).await.public;
+        let leaf_nodes = get_test_leaf_nodes(TEST_CIPHER_SUITE).await;
+
+        tree.add_leaves(leaf_nodes, &BasicIdentityProvider, &cipher_suite_provider)
+            .await
+            .unwrap();
+
+        let mut bundle = ProposalBundle::default();
+
+        let kp = test_key_package(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, "D").await;
+        let add = Proposal::Add(Box::new(kp.into()));
+
+        bundle.add(add, Sender::Member(0), ProposalSource::ByValue);
+
+        let update = UpdateProposal {
+            leaf_node: get_basic_test_node(TEST_CIPHER_SUITE, "A").await,
+        };
+
+        let update = Proposal::Update(update);
+        let pref = ProposalRef::new_fake(vec![1, 2, 3]);
+
+        bundle.add(update, Sender::Member(1), ProposalSource::ByReference(pref));
+
+        bundle.update_senders = vec![LeafIndex(1)];
+
+        let remove = RemoveProposal {
+            to_remove: LeafIndex(2),
+        };
+
+        let remove = Proposal::Remove(remove);
+
+        bundle.add(remove, Sender::Member(0), ProposalSource::ByValue);
+
+        tree.batch_edit(
+            &mut bundle,
+            &Default::default(),
+            &BasicIdentityProvider,
+            &cipher_suite_provider,
+            true,
+        )
+        .await
+        .unwrap();
+
+        assert_eq!(bundle.add_proposals().len(), 1);
+        assert_eq!(bundle.remove_proposals().len(), 1);
+        assert_eq!(bundle.update_proposals().len(), 1);
+    }
+
+    #[cfg(feature = "custom_proposal")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn custom_proposal_support() {
+        let cipher_suite_provider = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+        let mut tree = TreeKemPublic::new();
+
+        let test_proposal_type = ProposalType::from(42);
+
+        let mut leaf_nodes = get_test_leaf_nodes(TEST_CIPHER_SUITE).await;
+
+        leaf_nodes
+            .iter_mut()
+            .for_each(|n| n.capabilities.proposals.push(test_proposal_type));
+
+        tree.add_leaves(leaf_nodes, &BasicIdentityProvider, &cipher_suite_provider)
+            .await
+            .unwrap();
+
+        assert!(tree.can_support_proposal(test_proposal_type));
+        assert!(!tree.can_support_proposal(ProposalType::from(43)));
+
+        let test_node = get_basic_test_node(TEST_CIPHER_SUITE, "another").await;
+
+        tree.add_leaves(
+            vec![test_node],
+            &BasicIdentityProvider,
+            &cipher_suite_provider,
+        )
+        .await
+        .unwrap();
+
+        assert!(!tree.can_support_proposal(test_proposal_type));
+    }
+}
diff --git a/src/tree_kem/node.rs b/src/tree_kem/node.rs
new file mode 100644
index 0000000..8b7372f
--- /dev/null
+++ b/src/tree_kem/node.rs
@@ -0,0 +1,577 @@
+// 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::leaf_node::LeafNode;
+use crate::client::MlsError;
+use crate::crypto::HpkePublicKey;
+use crate::tree_kem::math as tree_math;
+use crate::tree_kem::parent_hash::ParentHash;
+use alloc::vec;
+use alloc::vec::Vec;
+use core::hash::Hash;
+use core::ops::{Deref, DerefMut};
+use mls_rs_codec::{MlsDecode, MlsEncode, MlsSize};
+use tree_math::{CopathNode, TreeIndex};
+
+#[derive(Clone, Debug, PartialEq, MlsSize, MlsEncode, MlsDecode)]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+pub(crate) struct Parent {
+    pub public_key: HpkePublicKey,
+    pub parent_hash: ParentHash,
+    pub unmerged_leaves: Vec<LeafIndex>,
+}
+
+#[derive(
+    Clone, Copy, Debug, Ord, PartialEq, PartialOrd, Hash, Eq, MlsSize, MlsEncode, MlsDecode,
+)]
+#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+pub struct LeafIndex(pub(crate) u32);
+
+impl LeafIndex {
+    pub fn new(i: u32) -> Self {
+        Self(i)
+    }
+}
+
+impl Deref for LeafIndex {
+    type Target = u32;
+
+    fn deref(&self) -> &Self::Target {
+        &self.0
+    }
+}
+
+impl From<&LeafIndex> for NodeIndex {
+    fn from(leaf_index: &LeafIndex) -> Self {
+        leaf_index.0 * 2
+    }
+}
+
+impl From<LeafIndex> for NodeIndex {
+    fn from(leaf_index: LeafIndex) -> Self {
+        leaf_index.0 * 2
+    }
+}
+
+pub(crate) type NodeIndex = u32;
+
+#[derive(Clone, Debug, PartialEq, MlsSize, MlsEncode, MlsDecode)]
+#[allow(clippy::large_enum_variant)]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[repr(u8)]
+//TODO: Research if this should actually be a Box<Leaf> for memory / performance reasons
+pub(crate) enum Node {
+    Leaf(LeafNode) = 1u8,
+    Parent(Parent) = 2u8,
+}
+
+impl Node {
+    pub fn public_key(&self) -> &HpkePublicKey {
+        match self {
+            Node::Parent(p) => &p.public_key,
+            Node::Leaf(l) => &l.public_key,
+        }
+    }
+}
+
+impl From<Parent> for Option<Node> {
+    fn from(p: Parent) -> Self {
+        Node::from(p).into()
+    }
+}
+
+impl From<LeafNode> for Option<Node> {
+    fn from(l: LeafNode) -> Self {
+        Node::from(l).into()
+    }
+}
+
+impl From<Parent> for Node {
+    fn from(p: Parent) -> Self {
+        Node::Parent(p)
+    }
+}
+
+impl From<LeafNode> for Node {
+    fn from(l: LeafNode) -> Self {
+        Node::Leaf(l)
+    }
+}
+
+pub(crate) trait NodeTypeResolver {
+    fn as_parent(&self) -> Result<&Parent, MlsError>;
+    fn as_parent_mut(&mut self) -> Result<&mut Parent, MlsError>;
+    fn as_leaf(&self) -> Result<&LeafNode, MlsError>;
+    fn as_leaf_mut(&mut self) -> Result<&mut LeafNode, MlsError>;
+    fn as_non_empty(&self) -> Result<&Node, MlsError>;
+}
+
+impl NodeTypeResolver for Option<Node> {
+    fn as_parent(&self) -> Result<&Parent, MlsError> {
+        self.as_ref()
+            .and_then(|n| match n {
+                Node::Parent(p) => Some(p),
+                Node::Leaf(_) => None,
+            })
+            .ok_or(MlsError::ExpectedNode)
+    }
+
+    fn as_parent_mut(&mut self) -> Result<&mut Parent, MlsError> {
+        self.as_mut()
+            .and_then(|n| match n {
+                Node::Parent(p) => Some(p),
+                Node::Leaf(_) => None,
+            })
+            .ok_or(MlsError::ExpectedNode)
+    }
+
+    fn as_leaf(&self) -> Result<&LeafNode, MlsError> {
+        self.as_ref()
+            .and_then(|n| match n {
+                Node::Parent(_) => None,
+                Node::Leaf(l) => Some(l),
+            })
+            .ok_or(MlsError::ExpectedNode)
+    }
+
+    fn as_leaf_mut(&mut self) -> Result<&mut LeafNode, MlsError> {
+        self.as_mut()
+            .and_then(|n| match n {
+                Node::Parent(_) => None,
+                Node::Leaf(l) => Some(l),
+            })
+            .ok_or(MlsError::ExpectedNode)
+    }
+
+    fn as_non_empty(&self) -> Result<&Node, MlsError> {
+        self.as_ref().ok_or(MlsError::UnexpectedEmptyNode)
+    }
+}
+
+#[derive(Clone, Debug, PartialEq, MlsSize, MlsEncode, MlsDecode, Default)]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+pub(crate) struct NodeVec(Vec<Option<Node>>);
+
+impl From<Vec<Option<Node>>> for NodeVec {
+    fn from(x: Vec<Option<Node>>) -> Self {
+        NodeVec(x)
+    }
+}
+
+impl Deref for NodeVec {
+    type Target = Vec<Option<Node>>;
+
+    fn deref(&self) -> &Self::Target {
+        &self.0
+    }
+}
+
+impl DerefMut for NodeVec {
+    fn deref_mut(&mut self) -> &mut Self::Target {
+        &mut self.0
+    }
+}
+
+impl NodeVec {
+    #[cfg(any(test, all(feature = "custom_proposal", feature = "tree_index")))]
+    pub fn occupied_leaf_count(&self) -> u32 {
+        self.non_empty_leaves().count() as u32
+    }
+
+    pub fn total_leaf_count(&self) -> u32 {
+        (self.len() as u32 / 2 + 1).next_power_of_two()
+    }
+
+    #[inline]
+    pub fn borrow_node(&self, index: NodeIndex) -> Result<&Option<Node>, MlsError> {
+        Ok(self.get(self.validate_index(index)?).unwrap_or(&None))
+    }
+
+    fn validate_index(&self, index: NodeIndex) -> Result<usize, MlsError> {
+        if (index as usize) >= self.len().next_power_of_two() {
+            Err(MlsError::InvalidNodeIndex(index))
+        } else {
+            Ok(index as usize)
+        }
+    }
+
+    #[cfg(test)]
+    fn empty_leaves(&mut self) -> impl Iterator<Item = (LeafIndex, &mut Option<Node>)> {
+        self.iter_mut()
+            .step_by(2)
+            .enumerate()
+            .filter(|(_, n)| n.is_none())
+            .map(|(i, n)| (LeafIndex(i as u32), n))
+    }
+
+    pub fn non_empty_leaves(&self) -> impl Iterator<Item = (LeafIndex, &LeafNode)> + '_ {
+        self.leaves()
+            .enumerate()
+            .filter_map(|(i, l)| l.map(|l| (LeafIndex(i as u32), l)))
+    }
+
+    pub fn non_empty_parents(&self) -> impl Iterator<Item = (NodeIndex, &Parent)> + '_ {
+        self.iter()
+            .enumerate()
+            .skip(1)
+            .step_by(2)
+            .map(|(i, n)| (i as NodeIndex, n))
+            .filter_map(|(i, n)| n.as_parent().ok().map(|p| (i, p)))
+    }
+
+    pub fn leaves(&self) -> impl Iterator<Item = Option<&LeafNode>> + '_ {
+        self.iter().step_by(2).map(|n| n.as_leaf().ok())
+    }
+
+    pub fn direct_copath(&self, index: LeafIndex) -> Vec<CopathNode<NodeIndex>> {
+        NodeIndex::from(index).direct_copath(&self.total_leaf_count())
+    }
+
+    // Section 8.4
+    // The filtered direct path of a node is obtained from the node's direct path by removing
+    // all nodes whose child on the nodes's copath has an empty resolution
+    pub fn filtered(&self, index: LeafIndex) -> Result<Vec<bool>, MlsError> {
+        Ok(NodeIndex::from(index)
+            .direct_copath(&self.total_leaf_count())
+            .into_iter()
+            .map(|cp| self.is_resolution_empty(cp.copath))
+            .collect())
+    }
+
+    #[inline]
+    pub fn is_blank(&self, index: NodeIndex) -> Result<bool, MlsError> {
+        self.borrow_node(index).map(|n| n.is_none())
+    }
+
+    #[inline]
+    pub fn is_leaf(&self, index: NodeIndex) -> bool {
+        index % 2 == 0
+    }
+
+    // Blank a previously filled leaf node, and return the existing leaf
+    pub fn blank_leaf_node(&mut self, leaf_index: LeafIndex) -> Result<LeafNode, MlsError> {
+        let node_index = self.validate_index(leaf_index.into())?;
+
+        match self.get_mut(node_index).and_then(Option::take) {
+            Some(Node::Leaf(l)) => Ok(l),
+            _ => Err(MlsError::RemovingNonExistingMember),
+        }
+    }
+
+    pub fn blank_direct_path(&mut self, leaf: LeafIndex) -> Result<(), MlsError> {
+        for i in self.direct_copath(leaf) {
+            if let Some(n) = self.get_mut(i.path as usize) {
+                *n = None
+            }
+        }
+
+        Ok(())
+    }
+
+    // Remove elements until the last node is non-blank
+    pub fn trim(&mut self) {
+        while self.last() == Some(&None) {
+            self.pop();
+        }
+    }
+
+    pub fn borrow_as_parent(&self, node_index: NodeIndex) -> Result<&Parent, MlsError> {
+        self.borrow_node(node_index).and_then(|n| n.as_parent())
+    }
+
+    pub fn borrow_as_parent_mut(&mut self, node_index: NodeIndex) -> Result<&mut Parent, MlsError> {
+        let index = self.validate_index(node_index)?;
+
+        self.get_mut(index)
+            .ok_or(MlsError::InvalidNodeIndex(node_index))?
+            .as_parent_mut()
+    }
+
+    pub fn borrow_as_leaf_mut(&mut self, index: LeafIndex) -> Result<&mut LeafNode, MlsError> {
+        let node_index = NodeIndex::from(index);
+        let index = self.validate_index(node_index)?;
+
+        self.get_mut(index)
+            .ok_or(MlsError::InvalidNodeIndex(node_index))?
+            .as_leaf_mut()
+    }
+
+    pub fn borrow_as_leaf(&self, index: LeafIndex) -> Result<&LeafNode, MlsError> {
+        let node_index = NodeIndex::from(index);
+        self.borrow_node(node_index).and_then(|n| n.as_leaf())
+    }
+
+    pub fn borrow_or_fill_node_as_parent(
+        &mut self,
+        node_index: NodeIndex,
+        public_key: &HpkePublicKey,
+    ) -> Result<&mut Parent, MlsError> {
+        let index = self.validate_index(node_index)?;
+
+        while self.len() <= index {
+            self.push(None);
+        }
+
+        self.get_mut(index)
+            .ok_or(MlsError::InvalidNodeIndex(node_index))
+            .and_then(|n| {
+                if n.is_none() {
+                    *n = Parent {
+                        public_key: public_key.clone(),
+                        parent_hash: ParentHash::empty(),
+                        unmerged_leaves: vec![],
+                    }
+                    .into();
+                }
+                n.as_parent_mut()
+            })
+    }
+
+    pub fn get_resolution_index(&self, index: NodeIndex) -> Result<Vec<NodeIndex>, MlsError> {
+        let mut indexes = vec![index];
+        let mut resolution = vec![];
+
+        while let Some(index) = indexes.pop() {
+            if let Some(Some(node)) = self.get(index as usize) {
+                resolution.push(index);
+
+                if let Node::Parent(p) = node {
+                    resolution.extend(p.unmerged_leaves.iter().map(NodeIndex::from));
+                }
+            } else if !index.is_leaf() {
+                indexes.push(index.right_unchecked());
+                indexes.push(index.left_unchecked());
+            }
+        }
+
+        Ok(resolution)
+    }
+
+    pub fn find_in_resolution(
+        &self,
+        index: NodeIndex,
+        to_find: Option<NodeIndex>,
+    ) -> Option<usize> {
+        let mut indexes = vec![index];
+        let mut resolution_len = 0;
+
+        while let Some(index) = indexes.pop() {
+            if let Some(Some(node)) = self.get(index as usize) {
+                if Some(index) == to_find || to_find.is_none() {
+                    return Some(resolution_len);
+                }
+
+                resolution_len += 1;
+
+                if let Node::Parent(p) = node {
+                    indexes.extend(p.unmerged_leaves.iter().map(NodeIndex::from));
+                }
+            } else if !index.is_leaf() {
+                indexes.push(index.right_unchecked());
+                indexes.push(index.left_unchecked());
+            }
+        }
+
+        None
+    }
+
+    pub fn is_resolution_empty(&self, index: NodeIndex) -> bool {
+        self.find_in_resolution(index, None).is_none()
+    }
+
+    pub(crate) fn next_empty_leaf(&self, start: LeafIndex) -> LeafIndex {
+        let mut n = NodeIndex::from(start) as usize;
+
+        while n < self.len() {
+            if self.0[n].is_none() {
+                return LeafIndex((n as u32) >> 1);
+            }
+
+            n += 2;
+        }
+
+        LeafIndex((self.len() as u32 + 1) >> 1)
+    }
+
+    /// If `index` fits in the current tree, inserts `leaf` at `index`. Else, inserts `leaf` as the
+    /// last leaf
+    pub fn insert_leaf(&mut self, index: LeafIndex, leaf: LeafNode) {
+        let node_index = (*index as usize) << 1;
+
+        if node_index > self.len() {
+            self.push(None);
+            self.push(None);
+        } else if self.is_empty() {
+            self.push(None);
+        }
+
+        self.0[node_index] = Some(leaf.into());
+    }
+}
+
+#[cfg(test)]
+pub(crate) mod test_utils {
+    use super::*;
+    use crate::{
+        client::test_utils::TEST_CIPHER_SUITE, tree_kem::leaf_node::test_utils::get_basic_test_node,
+    };
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(crate) async fn get_test_node_vec() -> NodeVec {
+        let mut nodes = vec![None; 7];
+
+        nodes[0] = get_basic_test_node(TEST_CIPHER_SUITE, "A").await.into();
+        nodes[4] = get_basic_test_node(TEST_CIPHER_SUITE, "C").await.into();
+
+        nodes[5] = Parent {
+            public_key: b"CD".to_vec().into(),
+            parent_hash: ParentHash::empty(),
+            unmerged_leaves: vec![LeafIndex(2)],
+        }
+        .into();
+
+        nodes[6] = get_basic_test_node(TEST_CIPHER_SUITE, "D").await.into();
+
+        NodeVec::from(nodes)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::{
+        client::test_utils::TEST_CIPHER_SUITE,
+        tree_kem::{
+            leaf_node::test_utils::get_basic_test_node, node::test_utils::get_test_node_vec,
+        },
+    };
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn node_key_getters() {
+        let test_node_parent: Node = Parent {
+            public_key: b"pub".to_vec().into(),
+            parent_hash: ParentHash::empty(),
+            unmerged_leaves: vec![],
+        }
+        .into();
+
+        let test_leaf = get_basic_test_node(TEST_CIPHER_SUITE, "B").await;
+        let test_node_leaf: Node = test_leaf.clone().into();
+
+        assert_eq!(test_node_parent.public_key().as_ref(), b"pub");
+        assert_eq!(test_node_leaf.public_key(), &test_leaf.public_key);
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_empty_leaves() {
+        let mut test_vec = get_test_node_vec().await;
+        let mut test_vec_clone = get_test_node_vec().await;
+        let empty_leaves: Vec<(LeafIndex, &mut Option<Node>)> = test_vec.empty_leaves().collect();
+        assert_eq!(
+            [(LeafIndex(1), &mut test_vec_clone[2])].as_ref(),
+            empty_leaves.as_slice()
+        );
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_direct_path() {
+        let test_vec = get_test_node_vec().await;
+        // Tree math is already tested in that module, just ensure equality
+        let expected = 0.direct_copath(&4);
+        let actual = test_vec.direct_copath(LeafIndex(0));
+        assert_eq!(actual, expected);
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_filtered_direct_path_co_path() {
+        let test_vec = get_test_node_vec().await;
+        let expected = [true, false];
+        let actual = test_vec.filtered(LeafIndex(0)).unwrap();
+        assert_eq!(actual, expected);
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_get_parent_node() {
+        let mut test_vec = get_test_node_vec().await;
+
+        // If the node is a leaf it should fail
+        assert!(test_vec.borrow_as_parent_mut(0).is_err());
+
+        // If the node index is out of range it should fail
+        assert!(test_vec
+            .borrow_as_parent_mut(test_vec.len() as u32)
+            .is_err());
+
+        // Otherwise it should succeed
+        let mut expected = Parent {
+            public_key: b"CD".to_vec().into(),
+            parent_hash: ParentHash::empty(),
+            unmerged_leaves: vec![LeafIndex(2)],
+        };
+
+        assert_eq!(test_vec.borrow_as_parent_mut(5).unwrap(), &mut expected);
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_get_resolution() {
+        let test_vec = get_test_node_vec().await;
+
+        let resolution_node_5 = test_vec.get_resolution_index(5).unwrap();
+        let resolution_node_2 = test_vec.get_resolution_index(2).unwrap();
+        let resolution_node_3 = test_vec.get_resolution_index(3).unwrap();
+
+        assert_eq!(&resolution_node_5, &[5, 4]);
+        assert!(resolution_node_2.is_empty());
+        assert_eq!(&resolution_node_3, &[0, 5, 4]);
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_get_or_fill_existing() {
+        let mut test_vec = get_test_node_vec().await;
+        let mut test_vec2 = test_vec.clone();
+
+        let expected = test_vec[5].as_parent_mut().unwrap();
+        let actual = test_vec2
+            .borrow_or_fill_node_as_parent(5, &Vec::new().into())
+            .unwrap();
+
+        assert_eq!(actual, expected);
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_get_or_fill_empty() {
+        let mut test_vec = get_test_node_vec().await;
+
+        let mut expected = Parent {
+            public_key: vec![0u8; 4].into(),
+            parent_hash: ParentHash::empty(),
+            unmerged_leaves: vec![],
+        };
+
+        let actual = test_vec
+            .borrow_or_fill_node_as_parent(1, &vec![0u8; 4].into())
+            .unwrap();
+
+        assert_eq!(actual, &mut expected);
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_leaf_count() {
+        let test_vec = get_test_node_vec().await;
+        assert_eq!(test_vec.len(), 7);
+        assert_eq!(test_vec.occupied_leaf_count(), 3);
+        assert_eq!(
+            test_vec.non_empty_leaves().count(),
+            test_vec.occupied_leaf_count() as usize
+        );
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_total_leaf_count() {
+        let test_vec = get_test_node_vec().await;
+        assert_eq!(test_vec.occupied_leaf_count(), 3);
+        assert_eq!(test_vec.total_leaf_count(), 4);
+    }
+}
diff --git a/src/tree_kem/parent_hash.rs b/src/tree_kem/parent_hash.rs
new file mode 100644
index 0000000..f04157a
--- /dev/null
+++ b/src/tree_kem/parent_hash.rs
@@ -0,0 +1,431 @@
+// 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, HpkePublicKey};
+use crate::tree_kem::math as tree_math;
+use crate::tree_kem::node::{LeafIndex, Node, NodeIndex};
+use crate::tree_kem::TreeKemPublic;
+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 tree_math::TreeIndex;
+
+use super::leaf_node::LeafNodeSource;
+
+#[cfg(feature = "std")]
+use std::collections::HashSet;
+
+#[cfg(not(feature = "std"))]
+use alloc::collections::BTreeSet;
+
+#[derive(Clone, Debug, MlsSize, MlsEncode)]
+struct ParentHashInput<'a> {
+    #[mls_codec(with = "mls_rs_codec::byte_vec")]
+    public_key: &'a HpkePublicKey,
+    #[mls_codec(with = "mls_rs_codec::byte_vec")]
+    parent_hash: &'a [u8],
+    #[mls_codec(with = "mls_rs_codec::byte_vec")]
+    original_sibling_tree_hash: &'a [u8],
+}
+
+#[derive(Clone, MlsSize, MlsEncode, MlsDecode, PartialEq, Eq)]
+#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+pub struct ParentHash(
+    #[mls_codec(with = "mls_rs_codec::byte_vec")]
+    #[cfg_attr(feature = "serde", serde(with = "mls_rs_core::vec_serde"))]
+    Vec<u8>,
+);
+
+impl Debug for ParentHash {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        mls_rs_core::debug::pretty_bytes(&self.0)
+            .named("ParentHash")
+            .fmt(f)
+    }
+}
+
+impl From<Vec<u8>> for ParentHash {
+    fn from(v: Vec<u8>) -> Self {
+        Self(v)
+    }
+}
+
+impl Deref for ParentHash {
+    type Target = Vec<u8>;
+
+    fn deref(&self) -> &Self::Target {
+        &self.0
+    }
+}
+
+impl ParentHash {
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn new<P: CipherSuiteProvider>(
+        cipher_suite_provider: &P,
+        public_key: &HpkePublicKey,
+        parent_hash: &ParentHash,
+        original_sibling_tree_hash: &[u8],
+    ) -> Result<Self, MlsError> {
+        let input = ParentHashInput {
+            public_key,
+            parent_hash,
+            original_sibling_tree_hash,
+        };
+
+        let input_bytes = input.mls_encode_to_vec()?;
+
+        let hash = cipher_suite_provider
+            .hash(&input_bytes)
+            .await
+            .map_err(|e| MlsError::CryptoProviderError(e.into_any_error()))?;
+
+        Ok(Self(hash))
+    }
+
+    pub fn empty() -> Self {
+        ParentHash(Vec::new())
+    }
+
+    pub fn matches(&self, hash: &ParentHash) -> bool {
+        //TODO: Constant time equals
+        hash == self
+    }
+}
+
+impl Node {
+    fn get_parent_hash(&self) -> Option<ParentHash> {
+        match self {
+            Node::Parent(p) => Some(p.parent_hash.clone()),
+            Node::Leaf(l) => match &l.leaf_node_source {
+                LeafNodeSource::Commit(parent_hash) => Some(parent_hash.clone()),
+                _ => None,
+            },
+        }
+    }
+}
+
+impl TreeKemPublic {
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn parent_hash_for_leaf<P: CipherSuiteProvider>(
+        &mut self,
+        cipher_suite_provider: &P,
+        index: LeafIndex,
+    ) -> Result<ParentHash, MlsError> {
+        let mut hash = ParentHash::empty();
+
+        for node in self.nodes.direct_copath(index).into_iter().rev() {
+            if self.nodes.is_resolution_empty(node.copath) {
+                continue;
+            }
+
+            let parent = self.nodes.borrow_as_parent_mut(node.path)?;
+
+            let calculated = ParentHash::new(
+                cipher_suite_provider,
+                &parent.public_key,
+                &hash,
+                &self.tree_hashes.current[node.copath as usize],
+            )
+            .await?;
+
+            (parent.parent_hash, hash) = (hash, calculated);
+        }
+
+        Ok(hash)
+    }
+
+    // Updates all of the required parent hash values, and returns the calculated parent hash value for the leaf node
+    // If an update path is provided, additionally verify that the calculated parent hash matches
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(crate) async fn update_parent_hashes<P: CipherSuiteProvider>(
+        &mut self,
+        index: LeafIndex,
+        verify_leaf_hash: bool,
+        cipher_suite_provider: &P,
+    ) -> Result<(), MlsError> {
+        // First update the relevant original hashes used for parent hash computation.
+        self.update_hashes(&[index], cipher_suite_provider).await?;
+
+        let leaf_hash = self
+            .parent_hash_for_leaf(cipher_suite_provider, index)
+            .await?;
+
+        let leaf = self.nodes.borrow_as_leaf_mut(index)?;
+
+        if verify_leaf_hash {
+            // Verify the parent hash of the new sender leaf node and update the parent hash values
+            // in the local tree
+            if let LeafNodeSource::Commit(parent_hash) = &leaf.leaf_node_source {
+                if !leaf_hash.matches(parent_hash) {
+                    return Err(MlsError::ParentHashMismatch);
+                }
+            } else {
+                return Err(MlsError::InvalidLeafNodeSource);
+            }
+        } else {
+            leaf.leaf_node_source = LeafNodeSource::Commit(leaf_hash);
+        }
+
+        // Update hashes after changes to the tree.
+        self.update_hashes(&[index], cipher_suite_provider).await
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(super) async fn validate_parent_hashes<P: CipherSuiteProvider>(
+        &self,
+        cipher_suite_provider: &P,
+    ) -> Result<(), MlsError> {
+        let original_hashes = self.compute_original_hashes(cipher_suite_provider).await?;
+
+        let nodes_to_validate = self
+            .nodes
+            .non_empty_parents()
+            .map(|(node_index, _)| node_index);
+
+        #[cfg(feature = "std")]
+        let mut nodes_to_validate = nodes_to_validate.collect::<HashSet<_>>();
+        #[cfg(not(feature = "std"))]
+        let mut nodes_to_validate = nodes_to_validate.collect::<BTreeSet<_>>();
+
+        let num_leaves = self.total_leaf_count();
+
+        // For each leaf l, validate all non-blank nodes on the chain from l up the tree.
+        for (leaf_index, _) in self.nodes.non_empty_leaves() {
+            let mut n = NodeIndex::from(leaf_index);
+
+            while let Some(mut ps) = n.parent_sibling(&num_leaves) {
+                // Find the first non-blank ancestor p of n and p's co-path child s.
+                while self.nodes.is_blank(ps.parent)? {
+                    // If we reached the root, we're done with this chain.
+                    let Some(ps_parent) = ps.parent.parent_sibling(&num_leaves) else {
+                        return Ok(());
+                    };
+
+                    ps = ps_parent;
+                }
+
+                // Check is n's parent_hash field matches the parent hash of p with co-path child s.
+                let p_parent = self.nodes.borrow_as_parent(ps.parent)?;
+
+                let n_node = self
+                    .nodes
+                    .borrow_node(n)?
+                    .as_ref()
+                    .ok_or(MlsError::ExpectedNode)?;
+
+                let calculated = ParentHash::new(
+                    cipher_suite_provider,
+                    &p_parent.public_key,
+                    &p_parent.parent_hash,
+                    &original_hashes[ps.sibling as usize],
+                )
+                .await?;
+
+                if n_node.get_parent_hash() == Some(calculated) {
+                    // Check that "n is in the resolution of c, and the intersection of p's unmerged_leaves with the subtree
+                    // under c is equal to the resolution of c with n removed".
+                    let Some(cp) = ps.sibling.parent_sibling(&num_leaves) else {
+                        return Err(MlsError::ParentHashMismatch);
+                    };
+
+                    let c = cp.sibling;
+                    let c_resolution = self.nodes.get_resolution_index(c)?.into_iter();
+
+                    #[cfg(feature = "std")]
+                    let mut c_resolution = c_resolution.collect::<HashSet<_>>();
+                    #[cfg(not(feature = "std"))]
+                    let mut c_resolution = c_resolution.collect::<BTreeSet<_>>();
+
+                    let p_unmerged_in_c_subtree = self
+                        .unmerged_in_subtree(ps.parent, c)?
+                        .iter()
+                        .copied()
+                        .map(|x| *x * 2);
+
+                    #[cfg(feature = "std")]
+                    let p_unmerged_in_c_subtree = p_unmerged_in_c_subtree.collect::<HashSet<_>>();
+                    #[cfg(not(feature = "std"))]
+                    let p_unmerged_in_c_subtree = p_unmerged_in_c_subtree.collect::<BTreeSet<_>>();
+
+                    if c_resolution.remove(&n)
+                        && c_resolution == p_unmerged_in_c_subtree
+                        && nodes_to_validate.remove(&ps.parent)
+                    {
+                        // If n's parent_hash field matches and p has not been validated yet, mark p as validated and continue.
+                        n = ps.parent;
+                    } else {
+                        // If p is validated for the second time, the check fails ("all non-blank parent nodes are covered by exactly one such chain").
+                        return Err(MlsError::ParentHashMismatch);
+                    }
+                } else {
+                    // If n's parent_hash field doesn't match, we're done with this chain.
+                    break;
+                }
+            }
+        }
+
+        // The check passes iff all non-blank nodes are validated.
+        if nodes_to_validate.is_empty() {
+            Ok(())
+        } else {
+            Err(MlsError::ParentHashMismatch)
+        }
+    }
+}
+
+#[cfg(test)]
+pub(crate) mod test_utils {
+
+    use super::*;
+    use crate::{
+        cipher_suite::CipherSuite,
+        crypto::test_utils::test_cipher_suite_provider,
+        identity::basic::BasicIdentityProvider,
+        tree_kem::{leaf_node::test_utils::get_basic_test_node, node::Parent},
+    };
+
+    use alloc::vec;
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(crate) async fn test_parent(
+        cipher_suite: CipherSuite,
+        unmerged_leaves: Vec<LeafIndex>,
+    ) -> Parent {
+        let (_, public_key) = test_cipher_suite_provider(cipher_suite)
+            .kem_generate()
+            .await
+            .unwrap();
+
+        Parent {
+            public_key,
+            parent_hash: ParentHash::empty(),
+            unmerged_leaves,
+        }
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(crate) async fn test_parent_node(
+        cipher_suite: CipherSuite,
+        unmerged_leaves: Vec<LeafIndex>,
+    ) -> Node {
+        Node::Parent(test_parent(cipher_suite, unmerged_leaves).await)
+    }
+
+    // Create figure 12 from MLS RFC
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(crate) async fn get_test_tree_fig_12(cipher_suite: CipherSuite) -> TreeKemPublic {
+        let cipher_suite_provider = test_cipher_suite_provider(cipher_suite);
+
+        let mut tree = TreeKemPublic::new();
+
+        let mut leaves = Vec::new();
+
+        for l in ["A", "B", "C", "D", "E", "F", "G"] {
+            leaves.push(get_basic_test_node(cipher_suite, l).await);
+        }
+
+        tree.add_leaves(leaves, &BasicIdentityProvider, &cipher_suite_provider)
+            .await
+            .unwrap();
+
+        tree.nodes[1] = Some(test_parent_node(cipher_suite, vec![]).await);
+        tree.nodes[3] = Some(test_parent_node(cipher_suite, vec![LeafIndex(3)]).await);
+
+        tree.nodes[7] =
+            Some(test_parent_node(cipher_suite, vec![LeafIndex(3), LeafIndex(6)]).await);
+
+        tree.nodes[9] = Some(test_parent_node(cipher_suite, vec![LeafIndex(5)]).await);
+
+        tree.nodes[11] =
+            Some(test_parent_node(cipher_suite, vec![LeafIndex(5), LeafIndex(6)]).await);
+
+        tree.update_parent_hashes(LeafIndex(0), false, &cipher_suite_provider)
+            .await
+            .unwrap();
+
+        tree.update_parent_hashes(LeafIndex(4), false, &cipher_suite_provider)
+            .await
+            .unwrap();
+
+        tree
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::client::test_utils::TEST_CIPHER_SUITE;
+    use crate::crypto::test_utils::test_cipher_suite_provider;
+    use crate::tree_kem::leaf_node::test_utils::get_basic_test_node;
+    use crate::tree_kem::leaf_node::LeafNodeSource;
+    use crate::tree_kem::test_utils::TreeWithSigners;
+    use crate::tree_kem::MlsError;
+    use assert_matches::assert_matches;
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_missing_parent_hash() {
+        let cs = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+        let mut test_tree = TreeWithSigners::make_full_tree(8, &cs).await.tree;
+
+        *test_tree.nodes.borrow_as_leaf_mut(LeafIndex(0)).unwrap() =
+            get_basic_test_node(TEST_CIPHER_SUITE, "foo").await;
+
+        let missing_parent_hash_res = test_tree
+            .update_parent_hashes(
+                LeafIndex(0),
+                true,
+                &test_cipher_suite_provider(TEST_CIPHER_SUITE),
+            )
+            .await;
+
+        assert_matches!(
+            missing_parent_hash_res,
+            Err(MlsError::InvalidLeafNodeSource)
+        );
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_parent_hash_mismatch() {
+        let cs = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+        let mut test_tree = TreeWithSigners::make_full_tree(8, &cs).await.tree;
+
+        let unexpected_parent_hash = ParentHash::from(hex!("f00d"));
+
+        test_tree
+            .nodes
+            .borrow_as_leaf_mut(LeafIndex(0))
+            .unwrap()
+            .leaf_node_source = LeafNodeSource::Commit(unexpected_parent_hash);
+
+        let invalid_parent_hash_res = test_tree
+            .update_parent_hashes(
+                LeafIndex(0),
+                true,
+                &test_cipher_suite_provider(TEST_CIPHER_SUITE),
+            )
+            .await;
+
+        assert_matches!(invalid_parent_hash_res, Err(MlsError::ParentHashMismatch));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_parent_hash_invalid() {
+        let cs = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+        let mut test_tree = TreeWithSigners::make_full_tree(8, &cs).await.tree;
+
+        test_tree.nodes[2] = None;
+
+        let res = test_tree
+            .validate_parent_hashes(&test_cipher_suite_provider(TEST_CIPHER_SUITE))
+            .await;
+
+        assert_matches!(res, Err(MlsError::ParentHashMismatch));
+    }
+}
diff --git a/src/tree_kem/path_secret.rs b/src/tree_kem/path_secret.rs
new file mode 100644
index 0000000..c9fce76
--- /dev/null
+++ b/src/tree_kem/path_secret.rs
@@ -0,0 +1,265 @@
+// 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, HpkePublicKey, HpkeSecretKey};
+use crate::group::key_schedule::kdf_derive_secret;
+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::error::IntoAnyError;
+use zeroize::Zeroizing;
+
+use super::hpke_encryption::HpkeEncryptable;
+
+#[derive(Clone, Eq, PartialEq, MlsSize, MlsEncode, MlsDecode)]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+pub struct PathSecret(
+    #[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 PathSecret {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        mls_rs_core::debug::pretty_bytes(&self.0)
+            .named("PathSecret")
+            .fmt(f)
+    }
+}
+
+impl Deref for PathSecret {
+    type Target = Vec<u8>;
+
+    fn deref(&self) -> &Self::Target {
+        &self.0
+    }
+}
+
+impl From<Vec<u8>> for PathSecret {
+    fn from(data: Vec<u8>) -> Self {
+        PathSecret(Zeroizing::new(data))
+    }
+}
+
+impl From<Zeroizing<Vec<u8>>> for PathSecret {
+    fn from(data: Zeroizing<Vec<u8>>) -> Self {
+        PathSecret(data)
+    }
+}
+
+impl PathSecret {
+    pub fn random<P: CipherSuiteProvider>(
+        cipher_suite_provider: &P,
+    ) -> Result<PathSecret, MlsError> {
+        cipher_suite_provider
+            .random_bytes_vec(cipher_suite_provider.kdf_extract_size())
+            .map(Into::into)
+            .map_err(|e| MlsError::CryptoProviderError(e.into_any_error()))
+    }
+
+    pub fn empty<P: CipherSuiteProvider>(cipher_suite_provider: &P) -> Self {
+        // Define commit_secret as the all-zero vector of the same length as a path_secret
+        PathSecret::from(vec![0u8; cipher_suite_provider.kdf_extract_size()])
+    }
+}
+
+impl HpkeEncryptable for PathSecret {
+    const ENCRYPT_LABEL: &'static str = "UpdatePathNode";
+
+    fn from_bytes(bytes: Vec<u8>) -> Result<Self, MlsError> {
+        Ok(Self(Zeroizing::new(bytes)))
+    }
+
+    fn get_bytes(&self) -> Result<Vec<u8>, MlsError> {
+        Ok(self.to_vec())
+    }
+}
+
+impl PathSecret {
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn to_hpke_key_pair<P: CipherSuiteProvider>(
+        &self,
+        cs: &P,
+    ) -> Result<(HpkeSecretKey, HpkePublicKey), MlsError> {
+        let node_secret = Zeroizing::new(kdf_derive_secret(cs, self, b"node").await?);
+
+        cs.kem_derive(&node_secret)
+            .await
+            .map_err(|e| MlsError::CryptoProviderError(e.into_any_error()))
+    }
+}
+
+#[derive(Clone, Debug)]
+pub struct PathSecretGenerator<'a, P> {
+    cipher_suite_provider: &'a P,
+    last: Option<PathSecret>,
+    starting_with: Option<PathSecret>,
+}
+
+impl<'a, P: CipherSuiteProvider> PathSecretGenerator<'a, P> {
+    pub fn new(cipher_suite_provider: &'a P) -> Self {
+        Self {
+            cipher_suite_provider,
+            last: None,
+            starting_with: None,
+        }
+    }
+
+    pub fn starting_with(cipher_suite_provider: &'a P, secret: PathSecret) -> Self {
+        Self {
+            starting_with: Some(secret),
+            ..Self::new(cipher_suite_provider)
+        }
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn next_secret(&mut self) -> Result<PathSecret, MlsError> {
+        let secret = if let Some(starting_with) = self.starting_with.take() {
+            Ok(starting_with)
+        } else if let Some(last) = self.last.take() {
+            kdf_derive_secret(self.cipher_suite_provider, &last, b"path")
+                .await
+                .map(PathSecret::from)
+        } else {
+            PathSecret::random(self.cipher_suite_provider)
+        }?;
+
+        self.last = Some(secret.clone());
+
+        Ok(secret)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use crate::{
+        cipher_suite::CipherSuite,
+        client::test_utils::TEST_CIPHER_SUITE,
+        crypto::test_utils::{
+            test_cipher_suite_provider, try_test_cipher_suite_provider, TestCryptoProvider,
+        },
+    };
+
+    use super::*;
+
+    use alloc::string::String;
+
+    #[cfg(target_arch = "wasm32")]
+    use wasm_bindgen_test::wasm_bindgen_test as test;
+
+    #[derive(serde::Deserialize, serde::Serialize)]
+    struct TestCase {
+        cipher_suite: u16,
+        generations: Vec<String>,
+    }
+
+    impl TestCase {
+        #[cfg(not(mls_build_async))]
+        #[cfg_attr(coverage_nightly, coverage(off))]
+        fn generate() -> Vec<TestCase> {
+            CipherSuite::all()
+                .map(
+                    #[cfg_attr(coverage_nightly, coverage(off))]
+                    |cipher_suite| {
+                        let cs_provider = test_cipher_suite_provider(cipher_suite);
+                        let mut generator = PathSecretGenerator::new(&cs_provider);
+
+                        let generations = (0..10)
+                            .map(|_| hex::encode(&*generator.next_secret().unwrap()))
+                            .collect();
+
+                        TestCase {
+                            cipher_suite: cipher_suite.into(),
+                            generations,
+                        }
+                    },
+                )
+                .collect()
+        }
+
+        #[cfg(mls_build_async)]
+        fn generate() -> Vec<TestCase> {
+            panic!("Tests cannot be generated in async mode");
+        }
+    }
+
+    fn load_test_cases() -> Vec<TestCase> {
+        load_test_case_json!(path_secret, TestCase::generate())
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_path_secret_generation() {
+        let cases = load_test_cases();
+
+        for test_case in cases {
+            let Some(cs_provider) = try_test_cipher_suite_provider(test_case.cipher_suite) else {
+                continue;
+            };
+
+            let first_secret = PathSecret::from(hex::decode(&test_case.generations[0]).unwrap());
+            let mut generator = PathSecretGenerator::starting_with(&cs_provider, first_secret);
+
+            for expected in &test_case.generations {
+                let generated = hex::encode(&*generator.next_secret().await.unwrap());
+                assert_eq!(expected, &generated);
+            }
+        }
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_first_path_is_random() {
+        let cs_provider = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+
+        let mut generator = PathSecretGenerator::new(&cs_provider);
+        let first_secret = generator.next_secret().await.unwrap();
+
+        for _ in 0..100 {
+            let mut next_generator = PathSecretGenerator::new(&cs_provider);
+            let next_secret = next_generator.next_secret().await.unwrap();
+            assert_ne!(first_secret, next_secret);
+        }
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_starting_with() {
+        let cs_provider = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+        let secret = PathSecret::random(&cs_provider).unwrap();
+
+        let mut generator = PathSecretGenerator::starting_with(&cs_provider, secret.clone());
+
+        let first_secret = generator.next_secret().await.unwrap();
+        let second_secret = generator.next_secret().await.unwrap();
+
+        assert_eq!(secret, first_secret);
+        assert_ne!(first_secret, second_secret);
+    }
+
+    #[test]
+    fn test_empty_path_secret() {
+        for cipher_suite in TestCryptoProvider::all_supported_cipher_suites() {
+            let cs_provider = test_cipher_suite_provider(cipher_suite);
+            let empty = PathSecret::empty(&cs_provider);
+            assert_eq!(
+                empty,
+                PathSecret::from(vec![0u8; cs_provider.kdf_extract_size()])
+            )
+        }
+    }
+
+    #[test]
+    fn test_random_path_secret() {
+        let cs_provider = test_cipher_suite_provider(CipherSuite::P256_AES128);
+        let initial = PathSecret::random(&cs_provider).unwrap();
+
+        for _ in 0..100 {
+            let next = PathSecret::random(&cs_provider).unwrap();
+            assert_ne!(next, initial);
+        }
+    }
+}
diff --git a/src/tree_kem/private.rs b/src/tree_kem/private.rs
new file mode 100644
index 0000000..1cc72ee
--- /dev/null
+++ b/src/tree_kem/private.rs
@@ -0,0 +1,310 @@
+// 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::Vec};
+
+use mls_rs_codec::{MlsDecode, MlsEncode, MlsSize};
+use mls_rs_core::crypto::HpkeSecretKey;
+
+use crate::{client::MlsError, crypto::CipherSuiteProvider};
+
+use super::{
+    math::leaf_lca_level,
+    node::LeafIndex,
+    path_secret::{PathSecret, PathSecretGenerator},
+    TreeKemPublic,
+};
+
+#[derive(Clone, Debug, MlsEncode, MlsDecode, MlsSize, Eq, PartialEq)]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[non_exhaustive]
+pub struct TreeKemPrivate {
+    pub self_index: LeafIndex,
+    pub secret_keys: Vec<Option<HpkeSecretKey>>,
+}
+
+impl TreeKemPrivate {
+    pub fn new_self_leaf(self_index: LeafIndex, leaf_secret: HpkeSecretKey) -> Self {
+        TreeKemPrivate {
+            self_index,
+            secret_keys: vec![Some(leaf_secret)],
+        }
+    }
+
+    pub fn new_for_external() -> Self {
+        TreeKemPrivate {
+            self_index: LeafIndex(0),
+            secret_keys: Default::default(),
+        }
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn update_secrets<P: CipherSuiteProvider>(
+        &mut self,
+        cipher_suite_provider: &P,
+        signer_index: LeafIndex,
+        path_secret: PathSecret,
+        public_tree: &TreeKemPublic,
+    ) -> Result<(), MlsError> {
+        // Identify the lowest common
+        // ancestor of the leaves at index and at GroupInfo.signer_index. Set the private key
+        // for this node to the private key derived from the path_secret.
+        let lca_index = leaf_lca_level(self.self_index.into(), signer_index.into()) as usize - 2;
+
+        // For each parent of the common ancestor, up to the root of the tree, derive a new
+        // path secret and set the private key for the node to the private key derived from the
+        // path secret. The private key MUST be the private key that corresponds to the public
+        // key in the node.
+
+        let mut node_secret_gen =
+            PathSecretGenerator::starting_with(cipher_suite_provider, path_secret);
+
+        let path = public_tree.nodes.direct_copath(self.self_index);
+        let filtered = &public_tree.nodes.filtered(self.self_index)?;
+        self.secret_keys.resize(path.len() + 1, None);
+
+        for (i, (n, f)) in path.iter().zip(filtered).enumerate().skip(lca_index) {
+            if *f {
+                continue;
+            }
+
+            let secret = node_secret_gen.next_secret().await?;
+
+            let expected_pub_key = public_tree
+                .nodes
+                .borrow_node(n.path)?
+                .as_ref()
+                .map(|n| n.public_key())
+                .ok_or(MlsError::PubKeyMismatch)?;
+
+            let (secret_key, public_key) = secret.to_hpke_key_pair(cipher_suite_provider).await?;
+
+            if expected_pub_key != &public_key {
+                return Err(MlsError::PubKeyMismatch);
+            }
+
+            // It's ok to use index directly because of the resize above
+            self.secret_keys[i + 1] = Some(secret_key);
+        }
+
+        Ok(())
+    }
+
+    #[cfg(feature = "by_ref_proposal")]
+    pub fn update_leaf(&mut self, new_leaf: HpkeSecretKey) {
+        self.secret_keys = vec![None; self.secret_keys.len()];
+        self.secret_keys[0] = Some(new_leaf);
+    }
+}
+
+#[cfg(test)]
+impl TreeKemPrivate {
+    pub fn new(self_index: LeafIndex) -> Self {
+        TreeKemPrivate {
+            self_index,
+            secret_keys: Default::default(),
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use assert_matches::assert_matches;
+
+    use crate::{
+        cipher_suite::CipherSuite,
+        client::test_utils::TEST_CIPHER_SUITE,
+        crypto::test_utils::test_cipher_suite_provider,
+        group::test_utils::{get_test_group_context, random_bytes},
+        identity::basic::BasicIdentityProvider,
+        tree_kem::{
+            kem::TreeKem,
+            leaf_node::test_utils::{
+                default_properties, get_basic_test_node, get_basic_test_node_sig_key,
+            },
+            math::TreeIndex,
+            node::LeafIndex,
+        },
+    };
+
+    use super::*;
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn random_hpke_secret_key() -> HpkeSecretKey {
+        let (secret, _) = test_cipher_suite_provider(TEST_CIPHER_SUITE)
+            .kem_derive(&random_bytes(32))
+            .await
+            .unwrap();
+
+        secret
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_create_self_leaf() {
+        let secret = random_hpke_secret_key().await;
+
+        let self_index = LeafIndex(42);
+
+        let private_key = TreeKemPrivate::new_self_leaf(self_index, secret.clone());
+
+        assert_eq!(private_key.self_index, self_index);
+        assert_eq!(private_key.secret_keys.len(), 1);
+        assert_eq!(private_key.secret_keys[0].as_ref().unwrap(), &secret)
+    }
+
+    // Create a ratchet tree for Alice, Bob and Charlie. Alice generates an update path for
+    // Charlie. Return (Public Tree, Charlie's private key, update path, path secret)
+    // The ratchet tree returned has leaf indexes as [alice, bob, charlie]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn update_secrets_setup(
+        cipher_suite: CipherSuite,
+    ) -> (TreeKemPublic, TreeKemPrivate, TreeKemPrivate, PathSecret) {
+        let cipher_suite_provider = test_cipher_suite_provider(cipher_suite);
+
+        let (alice_leaf, alice_hpke_secret, alice_signing) =
+            get_basic_test_node_sig_key(cipher_suite, "alice").await;
+
+        let bob_leaf = get_basic_test_node(cipher_suite, "bob").await;
+
+        let (charlie_leaf, charlie_hpke_secret, _charlie_signing) =
+            get_basic_test_node_sig_key(cipher_suite, "charlie").await;
+
+        // Create a new public tree with Alice
+        let (mut public_tree, mut alice_private) = TreeKemPublic::derive(
+            alice_leaf,
+            alice_hpke_secret,
+            &BasicIdentityProvider,
+            &Default::default(),
+        )
+        .await
+        .unwrap();
+
+        // Add bob and charlie to the tree
+        public_tree
+            .add_leaves(
+                vec![bob_leaf, charlie_leaf],
+                &BasicIdentityProvider,
+                &cipher_suite_provider,
+            )
+            .await
+            .unwrap();
+
+        // Alice's secret key is longer now
+        alice_private.secret_keys.resize(3, None);
+
+        // Generate an update path for Alice
+        let encap_gen = TreeKem::new(&mut public_tree, &mut alice_private)
+            .encap(
+                &mut get_test_group_context(42, cipher_suite).await,
+                &[],
+                &alice_signing,
+                default_properties(),
+                None,
+                &cipher_suite_provider,
+                #[cfg(test)]
+                &Default::default(),
+            )
+            .await
+            .unwrap();
+
+        // Get a path secret from Alice for Charlie
+        let path_secret = encap_gen.path_secrets[1].clone().unwrap();
+
+        // Private key for Charlie
+        let charlie_private = TreeKemPrivate::new_self_leaf(LeafIndex(2), charlie_hpke_secret);
+
+        (public_tree, charlie_private, alice_private, path_secret)
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_update_secrets() {
+        let cipher_suite = TEST_CIPHER_SUITE;
+
+        let (public_tree, mut charlie_private, alice_private, path_secret) =
+            update_secrets_setup(cipher_suite).await;
+
+        let existing_private = charlie_private.secret_keys.first().cloned().unwrap();
+
+        // Add the secrets for Charlie to his private key
+        charlie_private
+            .update_secrets(
+                &test_cipher_suite_provider(cipher_suite),
+                LeafIndex(0),
+                path_secret,
+                &public_tree,
+            )
+            .await
+            .unwrap();
+
+        // Make sure that Charlie's private key didn't lose keys
+        assert_eq!(charlie_private.secret_keys.len(), 3);
+
+        // Check that the intersection of the secret keys of Alice and Charlie matches.
+        // The intersection contains only the root.
+        assert_eq!(alice_private.secret_keys[2], charlie_private.secret_keys[2]);
+
+        assert_eq!(
+            charlie_private.secret_keys[0].as_ref(),
+            existing_private.as_ref()
+        );
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_update_secrets_key_mismatch() {
+        let cipher_suite = TEST_CIPHER_SUITE;
+
+        let (mut public_tree, mut charlie_private, _, path_secret) =
+            update_secrets_setup(cipher_suite).await;
+
+        // Sabotage the public tree
+        public_tree
+            .nodes
+            .borrow_as_parent_mut(public_tree.total_leaf_count().root())
+            .unwrap()
+            .public_key = random_bytes(32).into();
+
+        // Add the secrets for Charlie to his private key
+        let res = charlie_private
+            .update_secrets(
+                &test_cipher_suite_provider(cipher_suite),
+                LeafIndex(0),
+                path_secret,
+                &public_tree,
+            )
+            .await;
+
+        assert_matches!(res, Err(MlsError::PubKeyMismatch));
+    }
+
+    #[cfg(feature = "by_ref_proposal")]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn setup_direct_path(self_index: LeafIndex, leaf_count: u32) -> TreeKemPrivate {
+        let secret = random_hpke_secret_key().await;
+
+        let mut private_key = TreeKemPrivate::new_self_leaf(self_index, secret.clone());
+
+        private_key.secret_keys = (0..0.direct_copath(&leaf_count).len() + 1)
+            .map(|_| Some(secret.clone()))
+            .collect();
+
+        private_key
+    }
+
+    #[cfg(feature = "by_ref_proposal")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_update_leaf() {
+        let self_leaf = LeafIndex(42);
+        let mut private_key = setup_direct_path(self_leaf, 128).await;
+
+        let new_secret = random_hpke_secret_key().await;
+
+        private_key.update_leaf(new_secret.clone());
+
+        // The update operation should have removed all the other keys in our direct path we
+        // previously added
+        assert!(private_key.secret_keys.iter().skip(1).all(|n| n.is_none()));
+
+        // The secret key for our leaf should have been updated accordingly
+        assert_eq!(private_key.secret_keys.first().unwrap(), &Some(new_secret));
+    }
+}
diff --git a/src/tree_kem/tree_hash.rs b/src/tree_kem/tree_hash.rs
new file mode 100644
index 0000000..d9115e3
--- /dev/null
+++ b/src/tree_kem/tree_hash.rs
@@ -0,0 +1,432 @@
+// 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::leaf_node::LeafNode;
+use super::node::{LeafIndex, NodeVec};
+use super::tree_math::BfsIterTopDown;
+use crate::client::MlsError;
+use crate::crypto::CipherSuiteProvider;
+use crate::tree_kem::math as tree_math;
+use crate::tree_kem::node::Parent;
+use crate::tree_kem::TreeKemPublic;
+use alloc::collections::VecDeque;
+use alloc::vec;
+use alloc::vec::Vec;
+use core::fmt::{self, Debug};
+use itertools::Itertools;
+use mls_rs_codec::{MlsDecode, MlsEncode, MlsSize};
+use mls_rs_core::error::IntoAnyError;
+use tree_math::TreeIndex;
+
+use core::ops::Deref;
+
+#[derive(Clone, Default, MlsSize, MlsEncode, MlsDecode, PartialEq)]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+pub(crate) struct TreeHash(
+    #[mls_codec(with = "mls_rs_codec::byte_vec")]
+    #[cfg_attr(feature = "serde", serde(with = "mls_rs_core::vec_serde"))]
+    Vec<u8>,
+);
+
+impl Debug for TreeHash {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        mls_rs_core::debug::pretty_bytes(&self.0)
+            .named("TreeHash")
+            .fmt(f)
+    }
+}
+
+impl Deref for TreeHash {
+    type Target = [u8];
+
+    fn deref(&self) -> &Self::Target {
+        &self.0
+    }
+}
+
+#[derive(Clone, Debug, Default, MlsSize, MlsEncode, MlsDecode, PartialEq)]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+pub(crate) struct TreeHashes {
+    pub current: Vec<TreeHash>,
+}
+
+#[derive(Debug, MlsSize, MlsEncode)]
+struct LeafNodeHashInput<'a> {
+    leaf_index: LeafIndex,
+    leaf_node: Option<&'a LeafNode>,
+}
+
+#[derive(Debug, MlsSize, MlsEncode)]
+struct ParentNodeTreeHashInput<'a> {
+    parent_node: Option<&'a Parent>,
+    #[mls_codec(with = "mls_rs_codec::byte_vec")]
+    left_hash: &'a [u8],
+    #[mls_codec(with = "mls_rs_codec::byte_vec")]
+    right_hash: &'a [u8],
+}
+
+#[derive(Debug, MlsSize, MlsEncode)]
+#[repr(u8)]
+enum TreeHashInput<'a> {
+    Leaf(LeafNodeHashInput<'a>) = 1u8,
+    Parent(ParentNodeTreeHashInput<'a>) = 2u8,
+}
+
+impl TreeKemPublic {
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    #[inline(never)]
+    pub async fn tree_hash<P: CipherSuiteProvider>(
+        &mut self,
+        cipher_suite_provider: &P,
+    ) -> Result<Vec<u8>, MlsError> {
+        self.initialize_hashes(cipher_suite_provider).await?;
+        let root = self.total_leaf_count().root();
+        Ok(self.tree_hashes.current[root as usize].to_vec())
+    }
+
+    // Update hashes after `committer` makes changes to the tree. `path_blank` is the
+    // list of leaves whose paths were blanked, i.e. updates and removes.
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn update_hashes<P: CipherSuiteProvider>(
+        &mut self,
+        updated_leaves: &[LeafIndex],
+        cipher_suite_provider: &P,
+    ) -> Result<(), MlsError> {
+        let num_leaves = self.total_leaf_count();
+
+        let trailing_blanks = (0..num_leaves)
+            .rev()
+            .map_while(|l| {
+                self.tree_hashes
+                    .current
+                    .get(2 * l as usize)
+                    .is_none()
+                    .then_some(LeafIndex(l))
+            })
+            .collect::<Vec<_>>();
+
+        // Update the current hashes for direct paths of all modified leaves.
+        tree_hash(
+            &mut self.tree_hashes.current,
+            &self.nodes,
+            Some([updated_leaves, &trailing_blanks].concat()),
+            &[],
+            num_leaves,
+            cipher_suite_provider,
+        )
+        .await?;
+
+        Ok(())
+    }
+
+    // Initialize all hashes after creating / importing a tree.
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn initialize_hashes<P>(&mut self, cipher_suite_provider: &P) -> Result<(), MlsError>
+    where
+        P: CipherSuiteProvider,
+    {
+        if self.tree_hashes.current.is_empty() {
+            let num_leaves = self.total_leaf_count();
+
+            tree_hash(
+                &mut self.tree_hashes.current,
+                &self.nodes,
+                None,
+                &[],
+                num_leaves,
+                cipher_suite_provider,
+            )
+            .await?;
+        }
+
+        Ok(())
+    }
+
+    pub(crate) fn unmerged_in_subtree(
+        &self,
+        node_unmerged: u32,
+        subtree_root: u32,
+    ) -> Result<&[LeafIndex], MlsError> {
+        let unmerged = &self.nodes.borrow_as_parent(node_unmerged)?.unmerged_leaves;
+        let (left, right) = tree_math::subtree(subtree_root);
+        let mut start = 0;
+        while start < unmerged.len() && unmerged[start] < left {
+            start += 1;
+        }
+        let mut end = start;
+        while end < unmerged.len() && unmerged[end] < right {
+            end += 1;
+        }
+        Ok(&unmerged[start..end])
+    }
+
+    fn different_unmerged(&self, ancestor: u32, descendant: u32) -> Result<bool, MlsError> {
+        Ok(!self.nodes.is_blank(ancestor)?
+            && !self.nodes.is_blank(descendant)?
+            && self.unmerged_in_subtree(ancestor, descendant)?
+                != self.nodes.borrow_as_parent(descendant)?.unmerged_leaves)
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub(crate) async fn compute_original_hashes<P: CipherSuiteProvider>(
+        &self,
+        cipher_suite: &P,
+    ) -> Result<Vec<TreeHash>, MlsError> {
+        let num_leaves = self.nodes.total_leaf_count() as usize;
+        let root = (num_leaves as u32).root();
+
+        // The value `filtered_sets[n]` is a list of all ancestors `a` of `n` s.t. we have to compute
+        // the tree hash of `n` with the unmerged leaves of `a` filtered out.
+        let mut filtered_sets = vec![vec![]; num_leaves * 2 - 1];
+        filtered_sets[root as usize].push(root);
+        let mut tree_hashes = vec![vec![]; num_leaves * 2 - 1];
+
+        let bfs_iter = BfsIterTopDown::new(num_leaves).skip(1);
+
+        for n in bfs_iter {
+            let Some(ps) = (n as u32).parent_sibling(&(num_leaves as u32)) else {
+                break;
+            };
+
+            let p = ps.parent;
+            filtered_sets[n] = filtered_sets[p as usize].clone();
+
+            if self.different_unmerged(*filtered_sets[p as usize].last().unwrap(), p)? {
+                filtered_sets[n].push(p);
+
+                // Compute tree hash of `n` without unmerged leaves of `p`. This also computes the tree hash
+                // for any descendants of `n` added to `filtered_sets` later via `clone`.
+                let (start_leaf, end_leaf) = tree_math::subtree(n as u32);
+
+                tree_hash(
+                    &mut tree_hashes[p as usize],
+                    &self.nodes,
+                    Some((*start_leaf..*end_leaf).map(LeafIndex).collect_vec()),
+                    &self.nodes.borrow_as_parent(p)?.unmerged_leaves,
+                    num_leaves as u32,
+                    cipher_suite,
+                )
+                .await?;
+            }
+        }
+
+        // Set the `original_hashes` based on the computed `hashes`.
+        let mut original_hashes = vec![TreeHash::default(); num_leaves * 2 - 1];
+
+        // If root has unmerged leaves, we recompute it's original hash. Else, we can use the current hash.
+        let root_original = if !self.nodes.is_blank(root)? && !self.nodes.is_leaf(root) {
+            let root_unmerged = &self.nodes.borrow_as_parent(root)?.unmerged_leaves;
+
+            if !root_unmerged.is_empty() {
+                let mut hashes = vec![];
+
+                tree_hash(
+                    &mut hashes,
+                    &self.nodes,
+                    None,
+                    root_unmerged,
+                    num_leaves as u32,
+                    cipher_suite,
+                )
+                .await?;
+
+                Some(hashes)
+            } else {
+                None
+            }
+        } else {
+            None
+        };
+
+        for (i, hash) in original_hashes.iter_mut().enumerate() {
+            let a = filtered_sets[i].last().unwrap();
+            *hash = if self.nodes.is_blank(*a)? || a == &root {
+                if let Some(root_original) = &root_original {
+                    root_original[i].clone()
+                } else {
+                    self.tree_hashes.current[i].clone()
+                }
+            } else {
+                tree_hashes[*a as usize][i].clone()
+            }
+        }
+
+        Ok(original_hashes)
+    }
+}
+
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+async fn tree_hash<P: CipherSuiteProvider>(
+    hashes: &mut Vec<TreeHash>,
+    nodes: &NodeVec,
+    leaves_to_update: Option<Vec<LeafIndex>>,
+    filtered_leaves: &[LeafIndex],
+    num_leaves: u32,
+    cipher_suite_provider: &P,
+) -> Result<(), MlsError> {
+    let leaves_to_update =
+        leaves_to_update.unwrap_or_else(|| (0..num_leaves).map(LeafIndex).collect::<Vec<_>>());
+
+    // Resize the array in case the tree was extended or truncated
+    hashes.resize(num_leaves as usize * 2 - 1, TreeHash::default());
+
+    let mut node_queue = VecDeque::with_capacity(leaves_to_update.len());
+
+    for l in leaves_to_update.iter().filter(|l| ***l < num_leaves) {
+        let leaf = (!filtered_leaves.contains(l))
+            .then_some(nodes.borrow_as_leaf(*l).ok())
+            .flatten();
+
+        hashes[2 * **l as usize] = TreeHash(hash_for_leaf(*l, leaf, cipher_suite_provider).await?);
+
+        if let Some(ps) = (2 * **l).parent_sibling(&num_leaves) {
+            node_queue.push_back(ps.parent);
+        }
+    }
+
+    while let Some(n) = node_queue.pop_front() {
+        let hash = TreeHash(
+            hash_for_parent(
+                nodes.borrow_as_parent(n).ok(),
+                cipher_suite_provider,
+                filtered_leaves,
+                &hashes[n.left_unchecked() as usize],
+                &hashes[n.right_unchecked() as usize],
+            )
+            .await?,
+        );
+
+        hashes[n as usize] = hash;
+
+        if let Some(ps) = n.parent_sibling(&num_leaves) {
+            node_queue.push_back(ps.parent);
+        }
+    }
+
+    Ok(())
+}
+
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+async fn hash_for_leaf<P: CipherSuiteProvider>(
+    leaf_index: LeafIndex,
+    leaf_node: Option<&LeafNode>,
+    cipher_suite_provider: &P,
+) -> Result<Vec<u8>, MlsError> {
+    let input = TreeHashInput::Leaf(LeafNodeHashInput {
+        leaf_index,
+        leaf_node,
+    });
+
+    cipher_suite_provider
+        .hash(&input.mls_encode_to_vec()?)
+        .await
+        .map_err(|e| MlsError::CryptoProviderError(e.into_any_error()))
+}
+
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+async fn hash_for_parent<P: CipherSuiteProvider>(
+    parent_node: Option<&Parent>,
+    cipher_suite_provider: &P,
+    filtered: &[LeafIndex],
+    left_hash: &[u8],
+    right_hash: &[u8],
+) -> Result<Vec<u8>, MlsError> {
+    let mut parent_node = parent_node.cloned();
+
+    if let Some(ref mut parent_node) = parent_node {
+        parent_node
+            .unmerged_leaves
+            .retain(|unmerged_index| !filtered.contains(unmerged_index));
+    }
+
+    let input = TreeHashInput::Parent(ParentNodeTreeHashInput {
+        parent_node: parent_node.as_ref(),
+        left_hash,
+        right_hash,
+    });
+
+    cipher_suite_provider
+        .hash(&input.mls_encode_to_vec()?)
+        .await
+        .map_err(|e| MlsError::CryptoProviderError(e.into_any_error()))
+}
+
+#[cfg(test)]
+mod tests {
+    use mls_rs_codec::MlsDecode;
+
+    use crate::{
+        cipher_suite::CipherSuite,
+        crypto::test_utils::{test_cipher_suite_provider, try_test_cipher_suite_provider},
+        identity::basic::BasicIdentityProvider,
+        tree_kem::{node::NodeVec, parent_hash::test_utils::get_test_tree_fig_12},
+    };
+
+    use super::*;
+
+    #[derive(serde::Deserialize, serde::Serialize)]
+    struct TestCase {
+        cipher_suite: u16,
+        #[serde(with = "hex::serde")]
+        tree_data: Vec<u8>,
+        #[serde(with = "hex::serde")]
+        tree_hash: Vec<u8>,
+    }
+
+    impl TestCase {
+        #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+        #[cfg_attr(coverage_nightly, coverage(off))]
+        async fn generate() -> Vec<TestCase> {
+            let mut test_cases = Vec::new();
+
+            for cipher_suite in CipherSuite::all() {
+                let mut tree = get_test_tree_fig_12(cipher_suite).await;
+
+                test_cases.push(TestCase {
+                    cipher_suite: cipher_suite.into(),
+                    tree_data: tree.nodes.mls_encode_to_vec().unwrap(),
+                    tree_hash: tree
+                        .tree_hash(&test_cipher_suite_provider(cipher_suite))
+                        .await
+                        .unwrap(),
+                })
+            }
+
+            test_cases
+        }
+    }
+
+    #[cfg(mls_build_async)]
+    async fn load_test_cases() -> Vec<TestCase> {
+        load_test_case_json!(tree_hash, TestCase::generate().await)
+    }
+
+    #[cfg(not(mls_build_async))]
+    fn load_test_cases() -> Vec<TestCase> {
+        load_test_case_json!(tree_hash, TestCase::generate())
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_tree_hash() {
+        let cases = load_test_cases().await;
+
+        for one_case in cases {
+            let Some(cs_provider) = try_test_cipher_suite_provider(one_case.cipher_suite) else {
+                continue;
+            };
+
+            let mut tree = TreeKemPublic::import_node_data(
+                NodeVec::mls_decode(&mut &*one_case.tree_data).unwrap(),
+                &BasicIdentityProvider,
+                &Default::default(),
+            )
+            .await
+            .unwrap();
+
+            let calculated_hash = tree.tree_hash(&cs_provider).await.unwrap();
+
+            assert_eq!(calculated_hash, one_case.tree_hash);
+        }
+    }
+}
diff --git a/src/tree_kem/tree_index.rs b/src/tree_kem/tree_index.rs
new file mode 100644
index 0000000..4e6731a
--- /dev/null
+++ b/src/tree_kem/tree_index.rs
@@ -0,0 +1,505 @@
+// 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::*;
+#[cfg(feature = "tree_index")]
+use core::fmt::{self, Debug};
+
+#[cfg(all(feature = "tree_index", feature = "custom_proposal"))]
+use crate::group::proposal::ProposalType;
+
+#[cfg(feature = "tree_index")]
+use crate::identity::CredentialType;
+
+#[cfg(feature = "tree_index")]
+use mls_rs_core::crypto::SignaturePublicKey;
+
+#[cfg(all(feature = "tree_index", feature = "std"))]
+use itertools::Itertools;
+
+#[cfg(all(feature = "tree_index", not(feature = "std")))]
+use alloc::collections::{btree_map::Entry, BTreeMap};
+
+#[cfg(all(feature = "tree_index", feature = "std"))]
+use std::collections::{hash_map::Entry, HashMap};
+
+#[cfg(all(feature = "tree_index", not(feature = "std")))]
+use alloc::collections::BTreeSet;
+
+#[cfg(feature = "tree_index")]
+use mls_rs_core::crypto::HpkePublicKey;
+
+#[cfg(feature = "tree_index")]
+#[derive(Clone, Default, PartialEq, Eq, MlsSize, MlsEncode, MlsDecode, Hash, PartialOrd, Ord)]
+pub struct Identifier(#[mls_codec(with = "mls_rs_codec::byte_vec")] Vec<u8>);
+
+#[cfg(feature = "tree_index")]
+impl Debug for Identifier {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        mls_rs_core::debug::pretty_bytes(&self.0)
+            .named("Identifier")
+            .fmt(f)
+    }
+}
+
+#[cfg(all(feature = "tree_index", feature = "std"))]
+#[derive(Clone, Debug, Default, PartialEq, MlsSize, MlsEncode, MlsDecode)]
+pub struct TreeIndex {
+    credential_signature_key: HashMap<SignaturePublicKey, LeafIndex>,
+    hpke_key: HashMap<HpkePublicKey, LeafIndex>,
+    identities: HashMap<Identifier, LeafIndex>,
+    credential_type_counters: HashMap<CredentialType, TypeCounter>,
+    #[cfg(feature = "custom_proposal")]
+    proposal_type_counter: HashMap<ProposalType, u32>,
+}
+
+#[cfg(all(feature = "tree_index", not(feature = "std")))]
+#[derive(Clone, Debug, Default, PartialEq, MlsSize, MlsEncode, MlsDecode)]
+pub struct TreeIndex {
+    credential_signature_key: BTreeMap<SignaturePublicKey, LeafIndex>,
+    hpke_key: BTreeMap<HpkePublicKey, LeafIndex>,
+    identities: BTreeMap<Identifier, LeafIndex>,
+    credential_type_counters: BTreeMap<CredentialType, TypeCounter>,
+    #[cfg(feature = "custom_proposal")]
+    proposal_type_counter: BTreeMap<ProposalType, u32>,
+}
+
+#[cfg(feature = "tree_index")]
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+pub(super) async fn index_insert<I: IdentityProvider>(
+    tree_index: &mut TreeIndex,
+    new_leaf: &LeafNode,
+    new_leaf_idx: LeafIndex,
+    id_provider: &I,
+    extensions: &ExtensionList,
+) -> Result<(), MlsError> {
+    let new_id = id_provider
+        .identity(&new_leaf.signing_identity, extensions)
+        .await
+        .map_err(|e| MlsError::IdentityProviderError(e.into_any_error()))?;
+
+    tree_index.insert(new_leaf_idx, new_leaf, new_id)
+}
+
+#[cfg(not(feature = "tree_index"))]
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+pub(super) async fn index_insert<I: IdentityProvider>(
+    nodes: &NodeVec,
+    new_leaf: &LeafNode,
+    new_leaf_idx: LeafIndex,
+    id_provider: &I,
+    extensions: &ExtensionList,
+) -> Result<(), MlsError> {
+    let new_id = id_provider
+        .identity(&new_leaf.signing_identity, extensions)
+        .await
+        .map_err(|e| MlsError::IdentityProviderError(e.into_any_error()))?;
+
+    for (i, leaf) in nodes.non_empty_leaves().filter(|(i, _)| i != &new_leaf_idx) {
+        (new_leaf.public_key != leaf.public_key)
+            .then_some(())
+            .ok_or(MlsError::DuplicateLeafData(*i))?;
+
+        (new_leaf.signing_identity.signature_key != leaf.signing_identity.signature_key)
+            .then_some(())
+            .ok_or(MlsError::DuplicateLeafData(*i))?;
+
+        let id = id_provider
+            .identity(&leaf.signing_identity, extensions)
+            .await
+            .map_err(|e| MlsError::IdentityProviderError(e.into_any_error()))?;
+
+        (new_id != id)
+            .then_some(())
+            .ok_or(MlsError::DuplicateLeafData(*i))?;
+
+        let cred_type = leaf.signing_identity.credential.credential_type();
+
+        new_leaf
+            .capabilities
+            .credentials
+            .contains(&cred_type)
+            .then_some(())
+            .ok_or(MlsError::InUseCredentialTypeUnsupportedByNewLeaf)?;
+
+        let new_cred_type = new_leaf.signing_identity.credential.credential_type();
+
+        leaf.capabilities
+            .credentials
+            .contains(&new_cred_type)
+            .then_some(())
+            .ok_or(MlsError::CredentialTypeOfNewLeafIsUnsupported)?;
+    }
+
+    Ok(())
+}
+
+#[cfg(feature = "tree_index")]
+impl TreeIndex {
+    pub fn new() -> Self {
+        Default::default()
+    }
+
+    pub fn is_initialized(&self) -> bool {
+        !self.identities.is_empty()
+    }
+
+    fn insert(
+        &mut self,
+        index: LeafIndex,
+        leaf_node: &LeafNode,
+        identity: Vec<u8>,
+    ) -> Result<(), MlsError> {
+        let old_leaf_count = self.credential_signature_key.len();
+
+        let pub_key = leaf_node.signing_identity.signature_key.clone();
+        let credential_entry = self.credential_signature_key.entry(pub_key);
+
+        if let Entry::Occupied(entry) = credential_entry {
+            return Err(MlsError::DuplicateLeafData(**entry.get()));
+        }
+
+        let hpke_entry = self.hpke_key.entry(leaf_node.public_key.clone());
+
+        if let Entry::Occupied(entry) = hpke_entry {
+            return Err(MlsError::DuplicateLeafData(**entry.get()));
+        }
+
+        let identity_entry = self.identities.entry(Identifier(identity));
+        if let Entry::Occupied(entry) = identity_entry {
+            return Err(MlsError::DuplicateLeafData(**entry.get()));
+        }
+
+        let in_use_cred_type_unsupported_by_new_leaf = self
+            .credential_type_counters
+            .iter()
+            .filter_map(|(cred_type, counters)| Some(*cred_type).filter(|_| counters.used > 0))
+            .find(|cred_type| !leaf_node.capabilities.credentials.contains(cred_type));
+
+        if in_use_cred_type_unsupported_by_new_leaf.is_some() {
+            return Err(MlsError::InUseCredentialTypeUnsupportedByNewLeaf);
+        }
+
+        let new_leaf_cred_type = leaf_node.signing_identity.credential.credential_type();
+
+        let cred_type_counters = self
+            .credential_type_counters
+            .entry(new_leaf_cred_type)
+            .or_default();
+
+        if cred_type_counters.supported != old_leaf_count as u32 {
+            return Err(MlsError::CredentialTypeOfNewLeafIsUnsupported);
+        }
+
+        cred_type_counters.used += 1;
+
+        let credential_type_iter = leaf_node.capabilities.credentials.iter().copied();
+
+        #[cfg(feature = "std")]
+        let credential_type_iter = credential_type_iter.unique();
+
+        #[cfg(not(feature = "std"))]
+        let credential_type_iter = credential_type_iter.collect::<BTreeSet<_>>().into_iter();
+
+        // Credential type counter updates
+        credential_type_iter.for_each(|cred_type| {
+            self.credential_type_counters
+                .entry(cred_type)
+                .or_default()
+                .supported += 1;
+        });
+
+        #[cfg(feature = "custom_proposal")]
+        {
+            let proposal_type_iter = leaf_node.capabilities.proposals.iter().copied();
+
+            #[cfg(feature = "std")]
+            let proposal_type_iter = proposal_type_iter.unique();
+
+            #[cfg(not(feature = "std"))]
+            let proposal_type_iter = proposal_type_iter.collect::<BTreeSet<_>>().into_iter();
+
+            // Proposal type counter update
+            proposal_type_iter.for_each(|proposal_type| {
+                *self.proposal_type_counter.entry(proposal_type).or_default() += 1;
+            });
+        }
+
+        identity_entry.or_insert(index);
+        credential_entry.or_insert(index);
+        hpke_entry.or_insert(index);
+
+        Ok(())
+    }
+
+    pub(crate) fn get_leaf_index_with_identity(&self, identity: &[u8]) -> Option<LeafIndex> {
+        self.identities.get(&Identifier(identity.to_vec())).copied()
+    }
+
+    pub fn remove(&mut self, leaf_node: &LeafNode, identity: &[u8]) {
+        let existed = self
+            .identities
+            .remove(&Identifier(identity.to_vec()))
+            .is_some();
+
+        self.credential_signature_key
+            .remove(&leaf_node.signing_identity.signature_key);
+
+        self.hpke_key.remove(&leaf_node.public_key);
+
+        if !existed {
+            return;
+        }
+
+        // Decrement credential type counters
+        let leaf_cred_type = leaf_node.signing_identity.credential.credential_type();
+
+        if let Some(counters) = self.credential_type_counters.get_mut(&leaf_cred_type) {
+            counters.used -= 1;
+        }
+
+        let credential_type_iter = leaf_node.capabilities.credentials.iter();
+
+        #[cfg(feature = "std")]
+        let credential_type_iter = credential_type_iter.unique();
+
+        #[cfg(not(feature = "std"))]
+        let credential_type_iter = credential_type_iter.collect::<BTreeSet<_>>().into_iter();
+
+        credential_type_iter.for_each(|cred_type| {
+            if let Some(counters) = self.credential_type_counters.get_mut(cred_type) {
+                counters.supported -= 1;
+            }
+        });
+
+        #[cfg(feature = "custom_proposal")]
+        {
+            let proposal_type_iter = leaf_node.capabilities.proposals.iter();
+
+            #[cfg(feature = "std")]
+            let proposal_type_iter = proposal_type_iter.unique();
+
+            #[cfg(not(feature = "std"))]
+            let proposal_type_iter = proposal_type_iter.collect::<BTreeSet<_>>().into_iter();
+
+            // Decrement proposal type counters
+            proposal_type_iter.for_each(|proposal_type| {
+                if let Some(supported) = self.proposal_type_counter.get_mut(proposal_type) {
+                    *supported -= 1;
+                }
+            })
+        }
+    }
+
+    #[cfg(feature = "custom_proposal")]
+    pub fn count_supporting_proposal(&self, proposal_type: ProposalType) -> u32 {
+        self.proposal_type_counter
+            .get(&proposal_type)
+            .copied()
+            .unwrap_or_default()
+    }
+
+    #[cfg(test)]
+    pub fn len(&self) -> usize {
+        self.credential_signature_key.len()
+    }
+}
+
+#[cfg(feature = "tree_index")]
+#[derive(Clone, Debug, Default, PartialEq, MlsEncode, MlsDecode, MlsSize)]
+struct TypeCounter {
+    supported: u32,
+    used: u32,
+}
+
+#[cfg(feature = "tree_index")]
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::{
+        client::test_utils::TEST_CIPHER_SUITE,
+        tree_kem::leaf_node::test_utils::{get_basic_test_node, get_test_client_identity},
+    };
+    use alloc::format;
+    use assert_matches::assert_matches;
+
+    #[derive(Clone, Debug)]
+    struct TestData {
+        pub leaf_node: LeafNode,
+        pub index: LeafIndex,
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn get_test_data(index: LeafIndex) -> TestData {
+        let cipher_suite = TEST_CIPHER_SUITE;
+        let leaf_node = get_basic_test_node(cipher_suite, &format!("foo{}", index.0)).await;
+
+        TestData { leaf_node, index }
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn test_setup() -> (Vec<TestData>, TreeIndex) {
+        let mut test_data = Vec::new();
+
+        for i in 0..10 {
+            test_data.push(get_test_data(LeafIndex(i)).await);
+        }
+
+        let mut test_index = TreeIndex::new();
+
+        test_data.clone().into_iter().for_each(|d| {
+            test_index
+                .insert(
+                    d.index,
+                    &d.leaf_node,
+                    get_test_client_identity(&d.leaf_node),
+                )
+                .unwrap()
+        });
+
+        (test_data, test_index)
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_insert() {
+        let (test_data, test_index) = test_setup().await;
+
+        assert_eq!(test_index.credential_signature_key.len(), test_data.len());
+        assert_eq!(test_index.hpke_key.len(), test_data.len());
+
+        test_data.into_iter().enumerate().for_each(|(i, d)| {
+            let pub_key = d.leaf_node.signing_identity.signature_key;
+
+            assert_eq!(
+                test_index.credential_signature_key.get(&pub_key),
+                Some(&LeafIndex(i as u32))
+            );
+
+            assert_eq!(
+                test_index.hpke_key.get(&d.leaf_node.public_key),
+                Some(&LeafIndex(i as u32))
+            );
+        })
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_insert_duplicate_credential_key() {
+        let (test_data, mut test_index) = test_setup().await;
+
+        let before_error = test_index.clone();
+
+        let mut new_key_package = get_basic_test_node(TEST_CIPHER_SUITE, "foo").await;
+        new_key_package.signing_identity = test_data[1].leaf_node.signing_identity.clone();
+
+        let res = test_index.insert(
+            test_data[1].index,
+            &new_key_package,
+            get_test_client_identity(&new_key_package),
+        );
+
+        assert_matches!(res, Err(MlsError::DuplicateLeafData(index))
+                        if index == *test_data[1].index);
+
+        assert_eq!(before_error, test_index);
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_insert_duplicate_hpke_key() {
+        let cipher_suite = TEST_CIPHER_SUITE;
+        let (test_data, mut test_index) = test_setup().await;
+        let before_error = test_index.clone();
+
+        let mut new_leaf_node = get_basic_test_node(cipher_suite, "foo").await;
+        new_leaf_node.public_key = test_data[1].leaf_node.public_key.clone();
+
+        let res = test_index.insert(
+            test_data[1].index,
+            &new_leaf_node,
+            get_test_client_identity(&new_leaf_node),
+        );
+
+        assert_matches!(res, Err(MlsError::DuplicateLeafData(index))
+                        if index == *test_data[1].index);
+
+        assert_eq!(before_error, test_index);
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_remove() {
+        let (test_data, mut test_index) = test_setup().await;
+
+        test_index.remove(
+            &test_data[1].leaf_node,
+            &get_test_client_identity(&test_data[1].leaf_node),
+        );
+
+        assert_eq!(
+            test_index.credential_signature_key.len(),
+            test_data.len() - 1
+        );
+
+        assert_eq!(test_index.hpke_key.len(), test_data.len() - 1);
+
+        assert_eq!(
+            test_index
+                .credential_signature_key
+                .get(&test_data[1].leaf_node.signing_identity.signature_key),
+            None
+        );
+
+        assert_eq!(
+            test_index.hpke_key.get(&test_data[1].leaf_node.public_key),
+            None
+        );
+    }
+
+    #[cfg(feature = "custom_proposal")]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn custom_proposals() {
+        let test_proposal_id = ProposalType::new(42);
+        let other_proposal_id = ProposalType::new(45);
+
+        let mut test_data_1 = get_test_data(LeafIndex(0)).await;
+
+        test_data_1
+            .leaf_node
+            .capabilities
+            .proposals
+            .push(test_proposal_id);
+
+        let mut test_data_2 = get_test_data(LeafIndex(1)).await;
+
+        test_data_2
+            .leaf_node
+            .capabilities
+            .proposals
+            .push(test_proposal_id);
+
+        test_data_2
+            .leaf_node
+            .capabilities
+            .proposals
+            .push(other_proposal_id);
+
+        let mut test_index = TreeIndex::new();
+
+        test_index
+            .insert(test_data_1.index, &test_data_1.leaf_node, vec![0])
+            .unwrap();
+
+        assert_eq!(test_index.count_supporting_proposal(test_proposal_id), 1);
+
+        test_index
+            .insert(test_data_2.index, &test_data_2.leaf_node, vec![1])
+            .unwrap();
+
+        assert_eq!(test_index.count_supporting_proposal(test_proposal_id), 2);
+        assert_eq!(test_index.count_supporting_proposal(other_proposal_id), 1);
+
+        test_index.remove(&test_data_2.leaf_node, &[1]);
+
+        assert_eq!(test_index.count_supporting_proposal(test_proposal_id), 1);
+        assert_eq!(test_index.count_supporting_proposal(other_proposal_id), 0);
+    }
+}
diff --git a/src/tree_kem/tree_utils.rs b/src/tree_kem/tree_utils.rs
new file mode 100644
index 0000000..e7cdeb1
--- /dev/null
+++ b/src/tree_kem/tree_utils.rs
@@ -0,0 +1,191 @@
+// 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::string::String;
+use alloc::{format, vec};
+use core::borrow::BorrowMut;
+
+use debug_tree::TreeBuilder;
+
+use super::node::{NodeIndex, NodeVec};
+use crate::{client::MlsError, tree_kem::math::TreeIndex};
+
+pub(crate) fn build_tree(
+    tree: &mut TreeBuilder,
+    nodes: &NodeVec,
+    idx: NodeIndex,
+) -> Result<(), MlsError> {
+    let blank_tag = if nodes.is_blank(idx)? { "Blank " } else { "" };
+
+    // Leaf Node
+    if nodes.is_leaf(idx) {
+        let leaf_tag = format!("{blank_tag}Leaf ({idx})");
+        tree.add_leaf(&leaf_tag);
+        return Ok(());
+    }
+
+    // Parent Leaf
+    let mut parent_tag = format!("{blank_tag}Parent ({idx})");
+
+    if nodes.total_leaf_count().root() == idx {
+        parent_tag = format!("{blank_tag}Root ({idx})");
+    }
+
+    // Add unmerged leaves indexes
+    let unmerged_leaves_idxs = match nodes.borrow_as_parent(idx) {
+        Ok(parent) => parent
+            .unmerged_leaves
+            .iter()
+            .map(|leaf_idx| format!("{}", leaf_idx.0))
+            .collect(),
+        Err(_) => {
+            // Empty parent nodes throw `NotParent` error when borrow as Parent
+            vec![]
+        }
+    };
+
+    if !unmerged_leaves_idxs.is_empty() {
+        let unmerged_leaves_tag =
+            format!(" unmerged leaves idxs: {}", unmerged_leaves_idxs.join(","));
+        parent_tag.push_str(&unmerged_leaves_tag);
+    }
+
+    let mut branch = tree.add_branch(&parent_tag);
+
+    //This cannot panic, as we already checked that idx is not a leaf
+    build_tree(tree, nodes, idx.left_unchecked())?;
+    build_tree(tree, nodes, idx.right_unchecked())?;
+
+    branch.release();
+
+    Ok(())
+}
+
+pub(crate) fn build_ascii_tree(nodes: &NodeVec) -> String {
+    let leaves_count: u32 = nodes.total_leaf_count();
+    let mut tree = TreeBuilder::new();
+    build_tree(tree.borrow_mut(), nodes, leaves_count.root()).unwrap();
+    tree.string()
+}
+
+#[cfg(test)]
+mod tests {
+    use alloc::vec;
+
+    use crate::{
+        client::test_utils::TEST_CIPHER_SUITE,
+        crypto::test_utils::test_cipher_suite_provider,
+        identity::basic::BasicIdentityProvider,
+        tree_kem::{
+            node::Parent,
+            parent_hash::ParentHash,
+            test_utils::{get_test_leaf_nodes, get_test_tree},
+        },
+    };
+
+    use super::build_ascii_tree;
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn print_fully_populated_tree() {
+        let cipher_suite_provider = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+
+        // Create a tree
+        let mut tree = get_test_tree(TEST_CIPHER_SUITE).await.public;
+        let key_packages = get_test_leaf_nodes(TEST_CIPHER_SUITE).await;
+
+        tree.add_leaves(key_packages, &BasicIdentityProvider, &cipher_suite_provider)
+            .await
+            .unwrap();
+
+        let tree_str = concat!(
+            "Blank Root (3)\n",
+            "├╼ Blank Parent (1)\n",
+            "│ ├╼ Leaf (0)\n",
+            "│ └╼ Leaf (2)\n",
+            "└╼ Blank Parent (5)\n",
+            "  ├╼ Leaf (4)\n",
+            "  └╼ Leaf (6)",
+        );
+
+        assert_eq!(tree_str, build_ascii_tree(&tree.nodes));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn print_tree_blank_leaves() {
+        let cipher_suite_provider = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+
+        // Create a tree
+        let mut tree = get_test_tree(TEST_CIPHER_SUITE).await.public;
+        let key_packages = get_test_leaf_nodes(TEST_CIPHER_SUITE).await;
+
+        let to_remove = tree
+            .add_leaves(key_packages, &BasicIdentityProvider, &cipher_suite_provider)
+            .await
+            .unwrap()[0];
+
+        tree.remove_leaves(
+            vec![to_remove],
+            &BasicIdentityProvider,
+            &cipher_suite_provider,
+        )
+        .await
+        .unwrap();
+
+        let tree_str = concat!(
+            "Blank Root (3)\n",
+            "├╼ Blank Parent (1)\n",
+            "│ ├╼ Leaf (0)\n",
+            "│ └╼ Blank Leaf (2)\n",
+            "└╼ Blank Parent (5)\n",
+            "  ├╼ Leaf (4)\n",
+            "  └╼ Leaf (6)",
+        );
+
+        assert_eq!(tree_str, build_ascii_tree(&tree.nodes));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn print_tree_unmerged_leaves_on_parent() {
+        let cipher_suite_provider = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+
+        // Create a tree
+        let mut tree = get_test_tree(TEST_CIPHER_SUITE).await.public;
+        let key_packages = get_test_leaf_nodes(TEST_CIPHER_SUITE).await;
+
+        tree.add_leaves(
+            [key_packages[0].clone(), key_packages[1].clone()].to_vec(),
+            &BasicIdentityProvider,
+            &cipher_suite_provider,
+        )
+        .await
+        .unwrap();
+
+        tree.nodes[3] = Parent {
+            public_key: vec![].into(),
+            parent_hash: ParentHash::empty(),
+            unmerged_leaves: vec![],
+        }
+        .into();
+
+        tree.add_leaves(
+            [key_packages[2].clone()].to_vec(),
+            &BasicIdentityProvider,
+            &cipher_suite_provider,
+        )
+        .await
+        .unwrap();
+
+        let tree_str = concat!(
+            "Root (3) unmerged leaves idxs: 3\n",
+            "├╼ Blank Parent (1)\n",
+            "│ ├╼ Leaf (0)\n",
+            "│ └╼ Leaf (2)\n",
+            "└╼ Blank Parent (5)\n",
+            "  ├╼ Leaf (4)\n",
+            "  └╼ Leaf (6)",
+        );
+
+        assert_eq!(tree_str, build_ascii_tree(&tree.nodes));
+    }
+}
diff --git a/src/tree_kem/tree_validator.rs b/src/tree_kem/tree_validator.rs
new file mode 100644
index 0000000..26d4baf
--- /dev/null
+++ b/src/tree_kem/tree_validator.rs
@@ -0,0 +1,356 @@
+// 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 = "std")]
+use std::collections::HashSet;
+
+#[cfg(not(feature = "std"))]
+use alloc::{vec, vec::Vec};
+use tree_math::TreeIndex;
+
+use super::node::{Node, NodeIndex};
+use crate::client::MlsError;
+use crate::crypto::CipherSuiteProvider;
+use crate::group::GroupContext;
+use crate::iter::wrap_impl_iter;
+use crate::tree_kem::math as tree_math;
+use crate::tree_kem::{leaf_node_validator::LeafNodeValidator, TreeKemPublic};
+use mls_rs_core::identity::IdentityProvider;
+
+#[cfg(all(not(mls_build_async), feature = "rayon"))]
+use rayon::prelude::*;
+
+#[cfg(mls_build_async)]
+use futures::{StreamExt, TryStreamExt};
+
+pub(crate) struct TreeValidator<'a, C, CSP>
+where
+    C: IdentityProvider,
+    CSP: CipherSuiteProvider,
+{
+    expected_tree_hash: &'a [u8],
+    leaf_node_validator: LeafNodeValidator<'a, C, CSP>,
+    group_id: &'a [u8],
+    cipher_suite_provider: &'a CSP,
+}
+
+impl<'a, C: IdentityProvider, CSP: CipherSuiteProvider> TreeValidator<'a, C, CSP> {
+    pub fn new(
+        cipher_suite_provider: &'a CSP,
+        context: &'a GroupContext,
+        identity_provider: &'a C,
+    ) -> Self {
+        TreeValidator {
+            expected_tree_hash: &context.tree_hash,
+            leaf_node_validator: LeafNodeValidator::new(
+                cipher_suite_provider,
+                identity_provider,
+                Some(&context.extensions),
+            ),
+            group_id: &context.group_id,
+            cipher_suite_provider,
+        }
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn validate(&self, tree: &mut TreeKemPublic) -> Result<(), MlsError> {
+        self.validate_tree_hash(tree).await?;
+
+        tree.validate_parent_hashes(self.cipher_suite_provider)
+            .await?;
+
+        self.validate_no_trailing_blanks(tree)?;
+        self.validate_leaves(tree).await?;
+        validate_unmerged(tree)
+    }
+
+    fn validate_no_trailing_blanks(&self, tree: &TreeKemPublic) -> Result<(), MlsError> {
+        tree.nodes
+            .last()
+            .ok_or(MlsError::UnexpectedEmptyTree)?
+            .is_some()
+            .then_some(())
+            .ok_or(MlsError::UnexpectedTrailingBlanks)
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn validate_tree_hash(&self, tree: &mut TreeKemPublic) -> Result<(), MlsError> {
+        //Verify that the tree hash of the ratchet tree matches the tree_hash field in the GroupInfo.
+        let tree_hash = tree.tree_hash(self.cipher_suite_provider).await?;
+
+        if tree_hash != self.expected_tree_hash {
+            return Err(MlsError::TreeHashMismatch);
+        }
+
+        Ok(())
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn validate_leaves(&self, tree: &TreeKemPublic) -> Result<(), MlsError> {
+        let leaves = wrap_impl_iter(tree.nodes.non_empty_leaves());
+
+        #[cfg(mls_build_async)]
+        let leaves = leaves.map(Ok);
+
+        { leaves }
+            .try_for_each(|(index, leaf_node)| async move {
+                self.leaf_node_validator
+                    .revalidate(leaf_node, self.group_id, *index)
+                    .await
+            })
+            .await
+    }
+}
+
+fn validate_unmerged(tree: &TreeKemPublic) -> Result<(), MlsError> {
+    let unmerged_sets = tree.nodes.iter().map(|n| {
+        #[cfg(feature = "std")]
+        if let Some(Node::Parent(p)) = n {
+            HashSet::from_iter(p.unmerged_leaves.iter().cloned())
+        } else {
+            HashSet::new()
+        }
+
+        #[cfg(not(feature = "std"))]
+        if let Some(Node::Parent(p)) = n {
+            p.unmerged_leaves.clone()
+        } else {
+            vec![]
+        }
+    });
+
+    let mut unmerged_sets = unmerged_sets.collect::<Vec<_>>();
+
+    // For each leaf L, we search for the longest prefix P[1], P[2], ..., P[k] of the direct path of L
+    // such that for each i=1..k, either L is in the unmerged leaves of P[i], or P[i] is blank. We will
+    // then check that L is unmerged at each P[1], ..., P[k] and no other node.
+    let leaf_count = tree.total_leaf_count();
+
+    for (index, _) in tree.nodes.non_empty_leaves() {
+        let mut n = NodeIndex::from(index);
+
+        while let Some(ps) = n.parent_sibling(&leaf_count) {
+            if tree.nodes.is_blank(ps.parent)? {
+                n = ps.parent;
+                continue;
+            }
+
+            let parent_node = tree.nodes.borrow_as_parent(ps.parent)?;
+
+            if parent_node.unmerged_leaves.contains(&index) {
+                unmerged_sets[ps.parent as usize].retain(|i| i != &index);
+
+                n = ps.parent;
+            } else {
+                break;
+            }
+        }
+    }
+
+    let unmerged_sets = unmerged_sets.iter().all(|set| set.is_empty());
+
+    unmerged_sets
+        .then_some(())
+        .ok_or(MlsError::UnmergedLeavesMismatch)
+}
+
+#[cfg(test)]
+mod tests {
+    use alloc::vec;
+    use assert_matches::assert_matches;
+
+    use super::*;
+    use crate::{
+        cipher_suite::CipherSuite,
+        client::test_utils::TEST_CIPHER_SUITE,
+        crypto::test_utils::test_cipher_suite_provider,
+        crypto::test_utils::TestCryptoProvider,
+        group::test_utils::{get_test_group_context, random_bytes},
+        identity::basic::BasicIdentityProvider,
+        tree_kem::{
+            kem::TreeKem,
+            leaf_node::test_utils::{default_properties, get_basic_test_node},
+            node::{LeafIndex, Node, Parent},
+            parent_hash::{test_utils::get_test_tree_fig_12, ParentHash},
+            test_utils::get_test_tree,
+        },
+    };
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn test_parent_node(cipher_suite: CipherSuite) -> Parent {
+        let (_, public_key) = test_cipher_suite_provider(cipher_suite)
+            .kem_generate()
+            .await
+            .unwrap();
+
+        Parent {
+            public_key,
+            parent_hash: ParentHash::empty(),
+            unmerged_leaves: vec![],
+        }
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn get_valid_tree(cipher_suite: CipherSuite) -> TreeKemPublic {
+        let cipher_suite_provider = test_cipher_suite_provider(cipher_suite);
+
+        let mut test_tree = get_test_tree(cipher_suite).await;
+
+        let leaf1 = get_basic_test_node(cipher_suite, "leaf1").await;
+        let leaf2 = get_basic_test_node(cipher_suite, "leaf2").await;
+
+        test_tree
+            .public
+            .add_leaves(
+                vec![leaf1, leaf2],
+                &BasicIdentityProvider,
+                &cipher_suite_provider,
+            )
+            .await
+            .unwrap();
+
+        test_tree.public.nodes[1] = Some(Node::Parent(test_parent_node(cipher_suite).await));
+        test_tree.public.nodes[3] = Some(Node::Parent(test_parent_node(cipher_suite).await));
+
+        TreeKem::new(&mut test_tree.public, &mut test_tree.private)
+            .encap(
+                &mut get_test_group_context(42, cipher_suite).await,
+                &[LeafIndex(1), LeafIndex(2)],
+                &test_tree.creator_signing_key,
+                default_properties(),
+                None,
+                &cipher_suite_provider,
+                #[cfg(test)]
+                &Default::default(),
+            )
+            .await
+            .unwrap();
+
+        test_tree.public
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_valid_tree() {
+        for cipher_suite in TestCryptoProvider::all_supported_cipher_suites() {
+            let cipher_suite_provider = test_cipher_suite_provider(cipher_suite);
+
+            let mut test_tree = get_valid_tree(cipher_suite).await;
+
+            let mut context = get_test_group_context(1, cipher_suite).await;
+            context.tree_hash = test_tree.tree_hash(&cipher_suite_provider).await.unwrap();
+
+            let validator =
+                TreeValidator::new(&cipher_suite_provider, &context, &BasicIdentityProvider);
+
+            validator.validate(&mut test_tree).await.unwrap();
+        }
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_tree_hash_mismatch() {
+        for cipher_suite in TestCryptoProvider::all_supported_cipher_suites() {
+            let mut test_tree = get_valid_tree(cipher_suite).await;
+
+            let cipher_suite_provider = test_cipher_suite_provider(cipher_suite);
+            let context = get_test_group_context(1, cipher_suite).await;
+
+            let validator =
+                TreeValidator::new(&cipher_suite_provider, &context, &BasicIdentityProvider);
+
+            let res = validator.validate(&mut test_tree).await;
+
+            assert_matches!(res, Err(MlsError::TreeHashMismatch));
+        }
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_parent_hash_mismatch() {
+        for cipher_suite in TestCryptoProvider::all_supported_cipher_suites() {
+            let mut test_tree = get_valid_tree(cipher_suite).await;
+
+            let parent_node = test_tree.nodes.borrow_as_parent_mut(1).unwrap();
+            parent_node.parent_hash = ParentHash::from(random_bytes(32));
+
+            let cipher_suite_provider = test_cipher_suite_provider(cipher_suite);
+            let mut context = get_test_group_context(1, cipher_suite).await;
+            context.tree_hash = test_tree.tree_hash(&cipher_suite_provider).await.unwrap();
+
+            let validator =
+                TreeValidator::new(&cipher_suite_provider, &context, &BasicIdentityProvider);
+
+            let res = validator.validate(&mut test_tree).await;
+
+            assert_matches!(res, Err(MlsError::ParentHashMismatch));
+        }
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_key_package_validation_failure() {
+        for cipher_suite in TestCryptoProvider::all_supported_cipher_suites() {
+            let mut test_tree = get_valid_tree(cipher_suite).await;
+
+            test_tree
+                .nodes
+                .borrow_as_leaf_mut(LeafIndex(0))
+                .unwrap()
+                .signature = random_bytes(32);
+
+            let cipher_suite_provider = test_cipher_suite_provider(cipher_suite);
+            let mut context = get_test_group_context(1, cipher_suite).await;
+            context.tree_hash = test_tree.tree_hash(&cipher_suite_provider).await.unwrap();
+
+            let validator =
+                TreeValidator::new(&cipher_suite_provider, &context, &BasicIdentityProvider);
+
+            let res = validator.validate(&mut test_tree).await;
+
+            assert_matches!(res, Err(MlsError::InvalidSignature));
+        }
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn verify_unmerged_with_correct_tree() {
+        let tree = get_test_tree_fig_12(TEST_CIPHER_SUITE).await;
+        validate_unmerged(&tree).unwrap();
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn verify_unmerged_with_blank_leaf() {
+        let mut tree = get_test_tree_fig_12(TEST_CIPHER_SUITE).await;
+
+        // Blank leaf D unmerged at nodes 3, 7
+        tree.nodes[6] = None;
+
+        assert_matches!(
+            validate_unmerged(&tree),
+            Err(MlsError::UnmergedLeavesMismatch)
+        );
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn verify_unmerged_with_broken_path() {
+        let mut tree = get_test_tree_fig_12(TEST_CIPHER_SUITE).await;
+
+        // Make D with direct path [3, 7] unmerged at 7 but not 3
+        tree.nodes.borrow_as_parent_mut(3).unwrap().unmerged_leaves = vec![];
+
+        assert_matches!(
+            validate_unmerged(&tree),
+            Err(MlsError::UnmergedLeavesMismatch)
+        );
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn verify_unmerged_with_leaf_outside_tree() {
+        let mut tree = get_test_tree_fig_12(TEST_CIPHER_SUITE).await;
+
+        // Add leaf E from the right subtree of the root to unmerged leaves of node 1 on the left
+        tree.nodes.borrow_as_parent_mut(1).unwrap().unmerged_leaves = vec![LeafIndex(4)];
+
+        assert_matches!(
+            validate_unmerged(&tree),
+            Err(MlsError::UnmergedLeavesMismatch)
+        );
+    }
+}
diff --git a/src/tree_kem/update_path.rs b/src/tree_kem/update_path.rs
new file mode 100644
index 0000000..654c21f
--- /dev/null
+++ b/src/tree_kem/update_path.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 alloc::{vec, vec::Vec};
+use mls_rs_codec::{MlsDecode, MlsEncode, MlsSize};
+use mls_rs_core::{error::IntoAnyError, identity::IdentityProvider};
+
+use super::{
+    leaf_node::LeafNode,
+    leaf_node_validator::{LeafNodeValidator, ValidationContext},
+    node::LeafIndex,
+};
+use crate::{
+    client::MlsError,
+    crypto::{CipherSuiteProvider, HpkeCiphertext, HpkePublicKey},
+};
+use crate::{group::message_processor::ProvisionalState, time::MlsTime};
+
+#[derive(Clone, Debug, PartialEq, Eq, MlsSize, MlsEncode, MlsDecode)]
+#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+pub struct UpdatePathNode {
+    pub public_key: HpkePublicKey,
+    pub encrypted_path_secret: Vec<HpkeCiphertext>,
+}
+
+#[derive(Clone, Debug, PartialEq, MlsSize, MlsEncode, MlsDecode)]
+#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+pub struct UpdatePath {
+    pub leaf_node: LeafNode,
+    pub nodes: Vec<UpdatePathNode>,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct ValidatedUpdatePath {
+    pub leaf_node: LeafNode,
+    pub nodes: Vec<Option<UpdatePathNode>>,
+}
+
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+pub(crate) async fn validate_update_path<C: IdentityProvider, CSP: CipherSuiteProvider>(
+    identity_provider: &C,
+    cipher_suite_provider: &CSP,
+    path: UpdatePath,
+    state: &ProvisionalState,
+    sender: LeafIndex,
+    commit_time: Option<MlsTime>,
+) -> Result<ValidatedUpdatePath, MlsError> {
+    let group_context_extensions = &state.group_context.extensions;
+
+    let leaf_validator = LeafNodeValidator::new(
+        cipher_suite_provider,
+        identity_provider,
+        Some(group_context_extensions),
+    );
+
+    leaf_validator
+        .check_if_valid(
+            &path.leaf_node,
+            ValidationContext::Commit((&state.group_context.group_id, *sender, commit_time)),
+        )
+        .await?;
+
+    let check_identity_eq = state.applied_proposals.external_initializations.is_empty();
+
+    if check_identity_eq {
+        let existing_leaf = state.public_tree.nodes.borrow_as_leaf(sender)?;
+        let original_leaf_node = existing_leaf.clone();
+
+        identity_provider
+            .valid_successor(
+                &original_leaf_node.signing_identity,
+                &path.leaf_node.signing_identity,
+                group_context_extensions,
+            )
+            .await
+            .map_err(|e| MlsError::IdentityProviderError(e.into_any_error()))?
+            .then_some(())
+            .ok_or(MlsError::InvalidSuccessor)?;
+
+        (existing_leaf.public_key != path.leaf_node.public_key)
+            .then_some(())
+            .ok_or(MlsError::SameHpkeKey(*sender))?;
+    }
+
+    // Unfilter the update path
+    let filtered = state.public_tree.nodes.filtered(sender)?;
+    let mut unfiltered_nodes = vec![];
+    let mut i = 0;
+
+    for n in path.nodes {
+        while *filtered.get(i).ok_or(MlsError::WrongPathLen)? {
+            unfiltered_nodes.push(None);
+            i += 1;
+        }
+
+        unfiltered_nodes.push(Some(n));
+        i += 1;
+    }
+
+    Ok(ValidatedUpdatePath {
+        leaf_node: path.leaf_node,
+        nodes: unfiltered_nodes,
+    })
+}
+
+#[cfg(test)]
+mod tests {
+    use alloc::vec;
+    use assert_matches::assert_matches;
+
+    use crate::client::test_utils::TEST_CIPHER_SUITE;
+    use crate::crypto::test_utils::test_cipher_suite_provider;
+    use crate::crypto::HpkeCiphertext;
+    use crate::group::message_processor::ProvisionalState;
+    use crate::group::test_utils::{get_test_group_context, random_bytes, TEST_GROUP};
+    use crate::identity::basic::BasicIdentityProvider;
+    use crate::tree_kem::leaf_node::test_utils::default_properties;
+    use crate::tree_kem::leaf_node::test_utils::get_basic_test_node_sig_key;
+    use crate::tree_kem::leaf_node::LeafNodeSource;
+    use crate::tree_kem::node::LeafIndex;
+    use crate::tree_kem::parent_hash::ParentHash;
+    use crate::tree_kem::test_utils::{get_test_leaf_nodes, get_test_tree};
+    use crate::tree_kem::validate_update_path;
+
+    use super::{UpdatePath, UpdatePathNode};
+    use crate::{cipher_suite::CipherSuite, tree_kem::MlsError};
+
+    use alloc::vec::Vec;
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn test_update_path(cipher_suite: CipherSuite, cred: &str) -> UpdatePath {
+        let (mut leaf_node, _, signer) = get_basic_test_node_sig_key(cipher_suite, cred).await;
+
+        leaf_node.leaf_node_source = LeafNodeSource::Commit(ParentHash::from(hex!("beef")));
+
+        leaf_node
+            .commit(
+                &test_cipher_suite_provider(cipher_suite),
+                TEST_GROUP,
+                0,
+                default_properties(),
+                None,
+                &signer,
+            )
+            .await
+            .unwrap();
+
+        let node = UpdatePathNode {
+            public_key: random_bytes(32).into(),
+            encrypted_path_secret: vec![HpkeCiphertext {
+                kem_output: random_bytes(32),
+                ciphertext: random_bytes(32),
+            }],
+        };
+
+        UpdatePath {
+            leaf_node,
+            nodes: vec![node.clone(), node],
+        }
+    }
+
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn test_provisional_state(cipher_suite: CipherSuite) -> ProvisionalState {
+        let mut tree = get_test_tree(cipher_suite).await.public;
+        let leaf_nodes = get_test_leaf_nodes(cipher_suite).await;
+
+        tree.add_leaves(
+            leaf_nodes,
+            &BasicIdentityProvider,
+            &test_cipher_suite_provider(cipher_suite),
+        )
+        .await
+        .unwrap();
+
+        ProvisionalState {
+            public_tree: tree,
+            applied_proposals: Default::default(),
+            group_context: get_test_group_context(1, cipher_suite).await,
+            indexes_of_added_kpkgs: vec![],
+            external_init_index: None,
+            #[cfg(feature = "state_update")]
+            unused_proposals: vec![],
+        }
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_valid_leaf_node() {
+        let cipher_suite_provider = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+        let update_path = test_update_path(TEST_CIPHER_SUITE, "creator").await;
+
+        let validated = validate_update_path(
+            &BasicIdentityProvider,
+            &cipher_suite_provider,
+            update_path.clone(),
+            &test_provisional_state(TEST_CIPHER_SUITE).await,
+            LeafIndex(0),
+            None,
+        )
+        .await
+        .unwrap();
+
+        let expected = update_path.nodes.into_iter().map(Some).collect::<Vec<_>>();
+
+        assert_eq!(validated.nodes, expected);
+        assert_eq!(validated.leaf_node, update_path.leaf_node);
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_invalid_key_package() {
+        let cipher_suite_provider = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+        let mut update_path = test_update_path(TEST_CIPHER_SUITE, "creator").await;
+        update_path.leaf_node.signature = random_bytes(32);
+
+        let validated = validate_update_path(
+            &BasicIdentityProvider,
+            &cipher_suite_provider,
+            update_path,
+            &test_provisional_state(TEST_CIPHER_SUITE).await,
+            LeafIndex(0),
+            None,
+        )
+        .await;
+
+        assert_matches!(validated, Err(MlsError::InvalidSignature));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn validating_path_fails_with_different_identity() {
+        let cipher_suite_provider = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+        let cipher_suite = TEST_CIPHER_SUITE;
+        let update_path = test_update_path(cipher_suite, "foobar").await;
+
+        let validated = validate_update_path(
+            &BasicIdentityProvider,
+            &cipher_suite_provider,
+            update_path,
+            &test_provisional_state(cipher_suite).await,
+            LeafIndex(0),
+            None,
+        )
+        .await;
+
+        assert_matches!(validated, Err(MlsError::InvalidSuccessor));
+    }
+
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn validating_path_fails_with_same_hpke_key() {
+        let cipher_suite_provider = test_cipher_suite_provider(TEST_CIPHER_SUITE);
+        let update_path = test_update_path(TEST_CIPHER_SUITE, "creator").await;
+        let mut state = test_provisional_state(TEST_CIPHER_SUITE).await;
+
+        state
+            .public_tree
+            .nodes
+            .borrow_as_leaf_mut(LeafIndex(0))
+            .unwrap()
+            .public_key = update_path.leaf_node.public_key.clone();
+
+        let validated = validate_update_path(
+            &BasicIdentityProvider,
+            &cipher_suite_provider,
+            update_path,
+            &state,
+            LeafIndex(0),
+            None,
+        )
+        .await;
+
+        assert_matches!(validated, Err(MlsError::SameHpkeKey(_)));
+    }
+}