Merge remote-tracking branch 'origin/upstream' am: dab69ee29b am: 7df41c52a5

Original change: undetermined

Change-Id: I1761d4d9e4491febc89faeeb87a71ad9d7c2002a
Signed-off-by: Automerger Merge Worker <[email protected]>
diff --git a/.cargo_vcs_info.json b/.cargo_vcs_info.json
new file mode 100644
index 0000000..cba6a9d
--- /dev/null
+++ b/.cargo_vcs_info.json
@@ -0,0 +1,6 @@
+{
+  "git": {
+    "sha1": "d54563f15579db6482817d2e3dbdcbc10957908c"
+  },
+  "path_in_vcs": "mls-rs-uniffi"
+}
\ No newline at end of file
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..6350c33
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,60 @@
+# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
+#
+# When uploading crates to the registry Cargo will automatically
+# "normalize" Cargo.toml files for maximal compatibility
+# with all versions of Cargo and also rewrite `path` dependencies
+# to registry (e.g., crates.io) dependencies.
+#
+# If you are reading this file be aware that the original Cargo.toml
+# will likely look very different (and much more reasonable).
+# See Cargo.toml.orig for the original contents.
+
+[package]
+edition = "2021"
+rust-version = "1.68.2"
+name = "mls-rs-uniffi"
+version = "0.1.0"
+description = "An UniFFI-compatible implementation of Messaging Layer Security (RFC 9420)"
+homepage = "https://github.com/awslabs/mls-rs"
+keywords = [
+    "mls",
+    "e2ee",
+    "uniffi",
+]
+categories = ["cryptography"]
+license = "Apache-2.0 OR MIT"
+repository = "https://github.com/awslabs/mls-rs"
+
+[lib]
+name = "mls_rs_uniffi"
+crate-type = [
+    "cdylib",
+]
+
+[dependencies.async-trait]
+version = "0.1.77"
+
+[dependencies.maybe-async]
+version = "0.2.10"
+
+[dependencies.mls-rs]
+version = "0.39.0"
+
+[dependencies.mls-rs-core]
+version = "0.18.0"
+
+[dependencies.mls-rs-crypto-openssl]
+version = "0.9.0"
+
+[dependencies.thiserror]
+version = "1.0.57"
+
+[dependencies.uniffi]
+version = "0.26.0"
+
+[dev-dependencies.uniffi_bindgen]
+version = "0.26.0"
+
+[target."cfg(mls_build_async)".dependencies.tokio]
+version = "1.36.0"
+features = ["sync"]
diff --git a/LICENSE b/LICENSE
new file mode 120000
index 0000000..4ce7dad
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1 @@
+LICENSE-apache
\ No newline at end of file
diff --git a/LICENSE-apache b/LICENSE-apache
new file mode 100644
index 0000000..831fbc5
--- /dev/null
+++ b/LICENSE-apache
@@ -0,0 +1,176 @@
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, orother modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
diff --git a/LICENSE-mit b/LICENSE-mit
new file mode 100644
index 0000000..e547c4a
--- /dev/null
+++ b/LICENSE-mit
@@ -0,0 +1,9 @@
+MIT License
+
+Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including  without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to  the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN  NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE  SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/METADATA b/METADATA
new file mode 100644
index 0000000..21c4a6b
--- /dev/null
+++ b/METADATA
@@ -0,0 +1,21 @@
+name: "mls-rs-uniffi"
+description: "An UniFFI-compatible implementation of Messaging Layer Security (RFC 9420)"
+third_party {
+  identifier {
+    type: "crates.io"
+    value: "mls-rs-uniffi"
+  }
+  identifier {
+    type: "Archive"
+    value: "https://static.crates.io/crates/mls-rs-uniffi/mls-rs-uniffi-0.1.0.crate"
+    primary_source: true
+  }
+  version: "0.1.0"
+  # Dual-licensed, using the least restrictive per go/thirdpartylicenses#same.
+  license_type: NOTICE
+  last_upgrade_date {
+    year: 2024
+    month: 5
+    day: 10
+  }
+}
diff --git a/MODULE_LICENSE_APACHE2 b/MODULE_LICENSE_APACHE2
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/MODULE_LICENSE_APACHE2
diff --git a/OWNERS b/OWNERS
new file mode 100644
index 0000000..48bea6e
--- /dev/null
+++ b/OWNERS
@@ -0,0 +1,2 @@
+# Bug component: 688011
+include platform/prebuilts/rust:main:/OWNERS
diff --git a/cargo_embargo.json b/cargo_embargo.json
new file mode 100644
index 0000000..2a253ec
--- /dev/null
+++ b/cargo_embargo.json
@@ -0,0 +1,3 @@
+{
+    "extra_cfg": ["mls_build_async"]
+}
diff --git a/src/config.rs b/src/config.rs
new file mode 100644
index 0000000..4bf2e6b
--- /dev/null
+++ b/src/config.rs
@@ -0,0 +1,92 @@
+use std::fmt::Debug;
+use std::sync::Arc;
+
+use mls_rs::{
+    client_builder::{self, WithGroupStateStorage},
+    identity::basic,
+    storage_provider::in_memory::InMemoryGroupStateStorage,
+};
+use mls_rs_crypto_openssl::OpensslCryptoProvider;
+
+use self::group_state::{GroupStateStorage, GroupStateStorageAdapter};
+use crate::Error;
+
+pub mod group_state;
+
+#[derive(Debug, Clone)]
+pub(crate) struct ClientGroupStorage(Arc<dyn GroupStateStorage>);
+
+impl From<Arc<dyn GroupStateStorage>> for ClientGroupStorage {
+    fn from(value: Arc<dyn GroupStateStorage>) -> Self {
+        Self(value)
+    }
+}
+
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+#[cfg_attr(mls_build_async, maybe_async::must_be_async)]
+impl mls_rs_core::group::GroupStateStorage for ClientGroupStorage {
+    type Error = Error;
+
+    async fn state(&self, group_id: &[u8]) -> Result<Option<Vec<u8>>, Self::Error> {
+        self.0.state(group_id.to_vec()).await
+    }
+
+    async fn epoch(&self, group_id: &[u8], epoch_id: u64) -> Result<Option<Vec<u8>>, Self::Error> {
+        self.0.epoch(group_id.to_vec(), epoch_id).await
+    }
+
+    async fn write(
+        &mut self,
+        state: mls_rs_core::group::GroupState,
+        inserts: Vec<mls_rs_core::group::EpochRecord>,
+        updates: Vec<mls_rs_core::group::EpochRecord>,
+    ) -> Result<(), Self::Error> {
+        self.0
+            .write(
+                state.id,
+                state.data,
+                inserts.into_iter().map(Into::into).collect(),
+                updates.into_iter().map(Into::into).collect(),
+            )
+            .await
+    }
+
+    async fn max_epoch_id(&self, group_id: &[u8]) -> Result<Option<u64>, Self::Error> {
+        self.0.max_epoch_id(group_id.to_vec()).await
+    }
+}
+
+pub type UniFFIConfig = client_builder::WithIdentityProvider<
+    basic::BasicIdentityProvider,
+    client_builder::WithCryptoProvider<
+        OpensslCryptoProvider,
+        WithGroupStateStorage<ClientGroupStorage, client_builder::BaseConfig>,
+    >,
+>;
+
+#[derive(Debug, Clone, uniffi::Record)]
+pub struct ClientConfig {
+    pub group_state_storage: Arc<dyn GroupStateStorage>,
+    /// Use the ratchet tree extension. If this is false, then you
+    /// must supply `ratchet_tree` out of band to clients.
+    pub use_ratchet_tree_extension: bool,
+}
+
+impl Default for ClientConfig {
+    fn default() -> Self {
+        Self {
+            group_state_storage: Arc::new(GroupStateStorageAdapter::new(
+                InMemoryGroupStateStorage::new(),
+            )),
+            use_ratchet_tree_extension: true,
+        }
+    }
+}
+
+// TODO(mgeisler): turn into an associated function when UniFFI
+// supports them: https://github.com/mozilla/uniffi-rs/issues/1074.
+/// Create a client config with an in-memory group state storage.
+#[uniffi::export]
+pub fn client_config_default() -> ClientConfig {
+    ClientConfig::default()
+}
diff --git a/src/config/group_state.rs b/src/config/group_state.rs
new file mode 100644
index 0000000..8d20ff1
--- /dev/null
+++ b/src/config/group_state.rs
@@ -0,0 +1,128 @@
+use mls_rs::error::IntoAnyError;
+use std::fmt::Debug;
+#[cfg(not(mls_build_async))]
+use std::sync::Mutex;
+#[cfg(mls_build_async)]
+use tokio::sync::Mutex;
+
+use crate::Error;
+
+// TODO(mulmarta): we'd like to use EpochRecord from mls-rs-core but
+// this breaks the Python tests because using two crates makes UniFFI
+// generate a Python module which must be in a subdirectory of the
+// directory with test scripts which is not supported by the script we
+// use.
+#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, uniffi::Record)]
+pub struct EpochRecord {
+    /// A unique epoch identifier within a particular group.
+    pub id: u64,
+    pub data: Vec<u8>,
+}
+
+impl From<mls_rs_core::group::EpochRecord> for EpochRecord {
+    fn from(mls_rs_core::group::EpochRecord { id, data }: mls_rs_core::group::EpochRecord) -> Self {
+        Self { id, data }
+    }
+}
+
+impl From<EpochRecord> for mls_rs_core::group::EpochRecord {
+    fn from(EpochRecord { id, data }: EpochRecord) -> Self {
+        Self { id, data }
+    }
+}
+
+// When building for async, uniffi::export has to be applied _after_
+// maybe-async's injection of the async trait. When building for sync,
+// the order has to be the opposite.
+#[cfg_attr(mls_build_async, uniffi::export(with_foreign))]
+#[cfg_attr(mls_build_async, maybe_async::must_be_async)]
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+#[cfg_attr(not(mls_build_async), uniffi::export(with_foreign))]
+pub trait GroupStateStorage: Send + Sync + Debug {
+    async fn state(&self, group_id: Vec<u8>) -> Result<Option<Vec<u8>>, Error>;
+    async fn epoch(&self, group_id: Vec<u8>, epoch_id: u64) -> Result<Option<Vec<u8>>, Error>;
+
+    async fn write(
+        &self,
+        group_id: Vec<u8>,
+        group_state: Vec<u8>,
+        epoch_inserts: Vec<EpochRecord>,
+        epoch_updates: Vec<EpochRecord>,
+    ) -> Result<(), Error>;
+
+    async fn max_epoch_id(&self, group_id: Vec<u8>) -> Result<Option<u64>, Error>;
+}
+
+/// Adapt a mls-rs `GroupStateStorage` implementation.
+///
+/// This is used to adapt a mls-rs `GroupStateStorage` implementation
+/// to our own `GroupStateStorage` trait. This way we can use any
+/// standard mls-rs group state storage from the FFI layer.
+#[derive(Debug)]
+pub(crate) struct GroupStateStorageAdapter<S>(Mutex<S>);
+
+impl<S> GroupStateStorageAdapter<S> {
+    pub fn new(group_state_storage: S) -> GroupStateStorageAdapter<S> {
+        Self(Mutex::new(group_state_storage))
+    }
+
+    #[cfg(not(mls_build_async))]
+    fn inner(&self) -> std::sync::MutexGuard<'_, S> {
+        self.0.lock().unwrap()
+    }
+
+    #[cfg(mls_build_async)]
+    async fn inner(&self) -> tokio::sync::MutexGuard<'_, S> {
+        self.0.lock().await
+    }
+}
+
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+#[cfg_attr(mls_build_async, maybe_async::must_be_async)]
+impl<S, Err> GroupStateStorage for GroupStateStorageAdapter<S>
+where
+    S: mls_rs::GroupStateStorage<Error = Err> + Debug,
+    Err: IntoAnyError,
+{
+    async fn state(&self, group_id: Vec<u8>) -> Result<Option<Vec<u8>>, Error> {
+        self.inner()
+            .await
+            .state(&group_id)
+            .await
+            .map_err(|err| err.into_any_error().into())
+    }
+
+    async fn epoch(&self, group_id: Vec<u8>, epoch_id: u64) -> Result<Option<Vec<u8>>, Error> {
+        self.inner()
+            .await
+            .epoch(&group_id, epoch_id)
+            .await
+            .map_err(|err| err.into_any_error().into())
+    }
+
+    async fn write(
+        &self,
+        id: Vec<u8>,
+        data: Vec<u8>,
+        epoch_inserts: Vec<EpochRecord>,
+        epoch_updates: Vec<EpochRecord>,
+    ) -> Result<(), Error> {
+        self.inner()
+            .await
+            .write(
+                mls_rs_core::group::GroupState { id, data },
+                epoch_inserts.into_iter().map(Into::into).collect(),
+                epoch_updates.into_iter().map(Into::into).collect(),
+            )
+            .await
+            .map_err(|err| err.into_any_error().into())
+    }
+
+    async fn max_epoch_id(&self, group_id: Vec<u8>) -> Result<Option<u64>, Error> {
+        self.inner()
+            .await
+            .max_epoch_id(&group_id)
+            .await
+            .map_err(|err| err.into_any_error().into())
+    }
+}
diff --git a/src/lib.rs b/src/lib.rs
new file mode 100644
index 0000000..c1f4aad
--- /dev/null
+++ b/src/lib.rs
@@ -0,0 +1,929 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// Copyright by contributors to this project.
+// SPDX-License-Identifier: (Apache-2.0 OR MIT)
+
+//! UniFFI-compatible wrapper around mls-rs.
+//!
+//! This is an opinionated UniFFI-compatible wrapper around mls-rs:
+//!
+//! - Opinionated: the wrapper removes some flexiblity from mls-rs and
+//!   focuses on exposing the minimum functionality necessary for
+//!   messaging apps.
+//!
+//! - UniFFI-compatible: the wrapper exposes types annotated to be
+//!   used with [UniFFI]. This makes it possible to automatically
+//!   generate a Kotlin, Swift, ... code which calls into the Rust
+//!   code.
+//!
+//! [UniFFI]: https://mozilla.github.io/uniffi-rs/
+
+mod config;
+
+use std::sync::Arc;
+
+use config::{ClientConfig, UniFFIConfig};
+#[cfg(not(mls_build_async))]
+use std::sync::Mutex;
+#[cfg(mls_build_async)]
+use tokio::sync::Mutex;
+
+use mls_rs::error::{IntoAnyError, MlsError};
+use mls_rs::group;
+use mls_rs::identity::basic;
+use mls_rs::mls_rules;
+use mls_rs::{CipherSuiteProvider, CryptoProvider};
+use mls_rs_core::identity;
+use mls_rs_core::identity::{BasicCredential, IdentityProvider};
+use mls_rs_crypto_openssl::OpensslCryptoProvider;
+
+uniffi::setup_scaffolding!();
+
+/// Unwrap the `Arc` if there is a single strong reference, otherwise
+/// clone the inner value.
+fn arc_unwrap_or_clone<T: Clone>(arc: Arc<T>) -> T {
+    // TODO(mgeisler): use Arc::unwrap_or_clone from Rust 1.76.
+    match Arc::try_unwrap(arc) {
+        Ok(t) => t,
+        Err(arc) => (*arc).clone(),
+    }
+}
+
+#[derive(Debug, thiserror::Error, uniffi::Error)]
+#[uniffi(flat_error)]
+#[non_exhaustive]
+pub enum Error {
+    #[error("A mls-rs error occurred: {inner}")]
+    MlsError {
+        #[from]
+        inner: mls_rs::error::MlsError,
+    },
+    #[error("An unknown error occurred: {inner}")]
+    AnyError {
+        #[from]
+        inner: mls_rs::error::AnyError,
+    },
+    #[error("A data encoding error occurred: {inner}")]
+    MlsCodecError {
+        #[from]
+        inner: mls_rs_core::mls_rs_codec::Error,
+    },
+    #[error("Unexpected callback error in UniFFI: {inner}")]
+    UnexpectedCallbackError {
+        #[from]
+        inner: uniffi::UnexpectedUniFFICallbackError,
+    },
+}
+
+impl IntoAnyError for Error {}
+
+/// A [`mls_rs::crypto::SignaturePublicKey`] wrapper.
+#[derive(Clone, Debug, uniffi::Record)]
+pub struct SignaturePublicKey {
+    pub bytes: Vec<u8>,
+}
+
+impl From<mls_rs::crypto::SignaturePublicKey> for SignaturePublicKey {
+    fn from(public_key: mls_rs::crypto::SignaturePublicKey) -> Self {
+        Self {
+            bytes: public_key.to_vec(),
+        }
+    }
+}
+
+impl From<SignaturePublicKey> for mls_rs::crypto::SignaturePublicKey {
+    fn from(public_key: SignaturePublicKey) -> Self {
+        Self::new(public_key.bytes)
+    }
+}
+
+/// A [`mls_rs::crypto::SignatureSecretKey`] wrapper.
+#[derive(Clone, Debug, uniffi::Record)]
+pub struct SignatureSecretKey {
+    pub bytes: Vec<u8>,
+}
+
+impl From<mls_rs::crypto::SignatureSecretKey> for SignatureSecretKey {
+    fn from(secret_key: mls_rs::crypto::SignatureSecretKey) -> Self {
+        Self {
+            bytes: secret_key.as_bytes().to_vec(),
+        }
+    }
+}
+
+impl From<SignatureSecretKey> for mls_rs::crypto::SignatureSecretKey {
+    fn from(secret_key: SignatureSecretKey) -> Self {
+        Self::new(secret_key.bytes)
+    }
+}
+
+/// A ([`SignaturePublicKey`], [`SignatureSecretKey`]) pair.
+#[derive(uniffi::Record, Clone, Debug)]
+pub struct SignatureKeypair {
+    cipher_suite: CipherSuite,
+    public_key: SignaturePublicKey,
+    secret_key: SignatureSecretKey,
+}
+
+/// A [`mls_rs::ExtensionList`] wrapper.
+#[derive(uniffi::Object, Debug, Clone)]
+pub struct ExtensionList {
+    _inner: mls_rs::ExtensionList,
+}
+
+impl From<mls_rs::ExtensionList> for ExtensionList {
+    fn from(inner: mls_rs::ExtensionList) -> Self {
+        Self { _inner: inner }
+    }
+}
+
+/// A [`mls_rs::Extension`] wrapper.
+#[derive(uniffi::Object, Debug, Clone)]
+pub struct Extension {
+    _inner: mls_rs::Extension,
+}
+
+impl From<mls_rs::Extension> for Extension {
+    fn from(inner: mls_rs::Extension) -> Self {
+        Self { _inner: inner }
+    }
+}
+
+/// A [`mls_rs::Group`] and [`mls_rs::group::NewMemberInfo`] wrapper.
+#[derive(uniffi::Record, Clone)]
+pub struct JoinInfo {
+    /// The group that was joined.
+    pub group: Arc<Group>,
+    /// Group info extensions found within the Welcome message used to join
+    /// the group.
+    pub group_info_extensions: Arc<ExtensionList>,
+}
+
+#[derive(Copy, Clone, Debug, uniffi::Enum)]
+pub enum ProtocolVersion {
+    /// MLS version 1.0.
+    Mls10,
+}
+
+impl TryFrom<mls_rs::ProtocolVersion> for ProtocolVersion {
+    type Error = Error;
+
+    fn try_from(version: mls_rs::ProtocolVersion) -> Result<Self, Self::Error> {
+        match version {
+            mls_rs::ProtocolVersion::MLS_10 => Ok(ProtocolVersion::Mls10),
+            _ => Err(MlsError::UnsupportedProtocolVersion(version))?,
+        }
+    }
+}
+
+/// A [`mls_rs::MlsMessage`] wrapper.
+#[derive(Clone, Debug, uniffi::Object)]
+pub struct Message {
+    inner: mls_rs::MlsMessage,
+}
+
+impl From<mls_rs::MlsMessage> for Message {
+    fn from(inner: mls_rs::MlsMessage) -> Self {
+        Self { inner }
+    }
+}
+
+#[derive(Clone, Debug, uniffi::Object)]
+pub struct Proposal {
+    _inner: mls_rs::group::proposal::Proposal,
+}
+
+impl From<mls_rs::group::proposal::Proposal> for Proposal {
+    fn from(inner: mls_rs::group::proposal::Proposal) -> Self {
+        Self { _inner: inner }
+    }
+}
+
+/// Update of a member due to a commit.
+#[derive(Clone, Debug, uniffi::Record)]
+pub struct MemberUpdate {
+    pub prior: Arc<SigningIdentity>,
+    pub new: Arc<SigningIdentity>,
+}
+
+/// A set of roster updates due to a commit.
+#[derive(Clone, Debug, uniffi::Record)]
+pub struct RosterUpdate {
+    pub added: Vec<Arc<SigningIdentity>>,
+    pub removed: Vec<Arc<SigningIdentity>>,
+    pub updated: Vec<MemberUpdate>,
+}
+
+impl RosterUpdate {
+    // This is an associated function because it felt wrong to hide
+    // the clones in an `impl From<&mls_rs::identity::RosterUpdate>`.
+    fn new(roster_update: &mls_rs::identity::RosterUpdate) -> Self {
+        let added = roster_update
+            .added()
+            .iter()
+            .map(|member| Arc::new(member.signing_identity.clone().into()))
+            .collect();
+        let removed = roster_update
+            .removed()
+            .iter()
+            .map(|member| Arc::new(member.signing_identity.clone().into()))
+            .collect();
+        let updated = roster_update
+            .updated()
+            .iter()
+            .map(|update| MemberUpdate {
+                prior: Arc::new(update.prior.signing_identity.clone().into()),
+                new: Arc::new(update.new.signing_identity.clone().into()),
+            })
+            .collect();
+        RosterUpdate {
+            added,
+            removed,
+            updated,
+        }
+    }
+}
+
+/// A [`mls_rs::group::ReceivedMessage`] wrapper.
+#[derive(Clone, Debug, uniffi::Enum)]
+pub enum ReceivedMessage {
+    /// A decrypted application message.
+    ApplicationMessage {
+        sender: Arc<SigningIdentity>,
+        data: Vec<u8>,
+    },
+
+    /// A new commit was processed creating a new group state.
+    Commit {
+        committer: Arc<SigningIdentity>,
+        roster_update: RosterUpdate,
+    },
+
+    // TODO(mgeisler): rename to `Proposal` when
+    // https://github.com/awslabs/mls-rs/issues/98 is fixed.
+    /// A proposal was received.
+    ReceivedProposal {
+        sender: Arc<SigningIdentity>,
+        proposal: Arc<Proposal>,
+    },
+
+    /// Validated GroupInfo object.
+    GroupInfo,
+    /// Validated welcome message.
+    Welcome,
+    /// Validated key package.
+    KeyPackage,
+}
+
+/// Supported cipher suites.
+///
+/// This is a subset of the cipher suites found in
+/// [`mls_rs::CipherSuite`].
+#[derive(Copy, Clone, Debug, uniffi::Enum)]
+pub enum CipherSuite {
+    // TODO(mgeisler): add more cipher suites.
+    Curve25519Aes128,
+}
+
+impl From<CipherSuite> for mls_rs::CipherSuite {
+    fn from(cipher_suite: CipherSuite) -> mls_rs::CipherSuite {
+        match cipher_suite {
+            CipherSuite::Curve25519Aes128 => mls_rs::CipherSuite::CURVE25519_AES128,
+        }
+    }
+}
+
+impl TryFrom<mls_rs::CipherSuite> for CipherSuite {
+    type Error = Error;
+
+    fn try_from(cipher_suite: mls_rs::CipherSuite) -> Result<Self, Self::Error> {
+        match cipher_suite {
+            mls_rs::CipherSuite::CURVE25519_AES128 => Ok(CipherSuite::Curve25519Aes128),
+            _ => Err(MlsError::UnsupportedCipherSuite(cipher_suite))?,
+        }
+    }
+}
+
+/// Generate a MLS signature keypair.
+///
+/// This will use the default mls-lite crypto provider.
+///
+/// See [`mls_rs::CipherSuiteProvider::signature_key_generate`]
+/// for details.
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+#[cfg_attr(mls_build_async, maybe_async::must_be_async)]
+#[uniffi::export]
+pub async fn generate_signature_keypair(
+    cipher_suite: CipherSuite,
+) -> Result<SignatureKeypair, Error> {
+    let crypto_provider = mls_rs_crypto_openssl::OpensslCryptoProvider::default();
+    let cipher_suite_provider = crypto_provider
+        .cipher_suite_provider(cipher_suite.into())
+        .ok_or(MlsError::UnsupportedCipherSuite(cipher_suite.into()))?;
+
+    let (secret_key, public_key) = cipher_suite_provider
+        .signature_key_generate()
+        .await
+        .map_err(|err| MlsError::CryptoProviderError(err.into_any_error()))?;
+
+    Ok(SignatureKeypair {
+        cipher_suite,
+        public_key: public_key.into(),
+        secret_key: secret_key.into(),
+    })
+}
+
+/// An MLS client used to create key packages and manage groups.
+///
+/// See [`mls_rs::Client`] for details.
+#[derive(Clone, Debug, uniffi::Object)]
+pub struct Client {
+    inner: mls_rs::client::Client<UniFFIConfig>,
+}
+
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+#[cfg_attr(mls_build_async, maybe_async::must_be_async)]
+#[uniffi::export]
+impl Client {
+    /// Create a new client.
+    ///
+    /// The user is identified by `id`, which will be used to create a
+    /// basic credential together with the signature keypair.
+    ///
+    /// See [`mls_rs::Client::builder`] for details.
+    #[uniffi::constructor]
+    pub fn new(
+        id: Vec<u8>,
+        signature_keypair: SignatureKeypair,
+        client_config: ClientConfig,
+    ) -> Self {
+        let cipher_suite = signature_keypair.cipher_suite;
+        let public_key = signature_keypair.public_key;
+        let secret_key = signature_keypair.secret_key;
+        let crypto_provider = OpensslCryptoProvider::new();
+        let basic_credential = BasicCredential::new(id);
+        let signing_identity =
+            identity::SigningIdentity::new(basic_credential.into_credential(), public_key.into());
+        let commit_options = mls_rules::CommitOptions::default()
+            .with_ratchet_tree_extension(client_config.use_ratchet_tree_extension)
+            .with_single_welcome_message(true);
+        let mls_rules = mls_rules::DefaultMlsRules::new().with_commit_options(commit_options);
+        let client = mls_rs::Client::builder()
+            .crypto_provider(crypto_provider)
+            .identity_provider(basic::BasicIdentityProvider::new())
+            .signing_identity(signing_identity, secret_key.into(), cipher_suite.into())
+            .group_state_storage(client_config.group_state_storage.into())
+            .mls_rules(mls_rules)
+            .build();
+
+        Client { inner: client }
+    }
+
+    /// Generate a new key package for this client.
+    ///
+    /// The key package is represented in is MLS message form. It is
+    /// needed when joining a group and can be published to a server
+    /// so other clients can look it up.
+    ///
+    /// See [`mls_rs::Client::generate_key_package_message`] for
+    /// details.
+    pub async fn generate_key_package_message(&self) -> Result<Message, Error> {
+        let message = self.inner.generate_key_package_message().await?;
+        Ok(message.into())
+    }
+
+    pub fn signing_identity(&self) -> Result<Arc<SigningIdentity>, Error> {
+        let (signing_identity, _) = self.inner.signing_identity()?;
+        Ok(Arc::new(signing_identity.clone().into()))
+    }
+
+    /// Create and immediately join a new group.
+    ///
+    /// If a group ID is not given, the underlying library will create
+    /// a unique ID for you.
+    ///
+    /// See [`mls_rs::Client::create_group`] and
+    /// [`mls_rs::Client::create_group_with_id`] for details.
+    pub async fn create_group(&self, group_id: Option<Vec<u8>>) -> Result<Group, Error> {
+        let extensions = mls_rs::ExtensionList::new();
+        let inner = match group_id {
+            Some(group_id) => {
+                self.inner
+                    .create_group_with_id(group_id, extensions)
+                    .await?
+            }
+            None => self.inner.create_group(extensions).await?,
+        };
+        Ok(Group {
+            inner: Arc::new(Mutex::new(inner)),
+        })
+    }
+
+    /// Join an existing group.
+    ///
+    /// You must supply `ratchet_tree` if the client that created
+    /// `welcome_message` did not set `use_ratchet_tree_extension`.
+    ///
+    /// See [`mls_rs::Client::join_group`] for details.
+    pub async fn join_group(
+        &self,
+        ratchet_tree: Option<RatchetTree>,
+        welcome_message: &Message,
+    ) -> Result<JoinInfo, Error> {
+        let ratchet_tree = ratchet_tree.map(TryInto::try_into).transpose()?;
+        let (group, new_member_info) = self
+            .inner
+            .join_group(ratchet_tree, &welcome_message.inner)
+            .await?;
+
+        let group = Arc::new(Group {
+            inner: Arc::new(Mutex::new(group)),
+        });
+        let group_info_extensions = Arc::new(new_member_info.group_info_extensions.into());
+        Ok(JoinInfo {
+            group,
+            group_info_extensions,
+        })
+    }
+
+    /// Load an existing group.
+    ///
+    /// See [`mls_rs::Client::load_group`] for details.
+    pub async fn load_group(&self, group_id: Vec<u8>) -> Result<Group, Error> {
+        self.inner
+            .load_group(&group_id)
+            .await
+            .map(|g| Group {
+                inner: Arc::new(Mutex::new(g)),
+            })
+            .map_err(Into::into)
+    }
+}
+
+#[derive(Clone, Debug, PartialEq, uniffi::Record)]
+pub struct RatchetTree {
+    pub bytes: Vec<u8>,
+}
+
+impl TryFrom<mls_rs::group::ExportedTree<'_>> for RatchetTree {
+    type Error = Error;
+
+    fn try_from(exported_tree: mls_rs::group::ExportedTree<'_>) -> Result<Self, Error> {
+        let bytes = exported_tree.to_bytes()?;
+        Ok(Self { bytes })
+    }
+}
+
+impl TryFrom<RatchetTree> for group::ExportedTree<'static> {
+    type Error = Error;
+
+    fn try_from(ratchet_tree: RatchetTree) -> Result<Self, Error> {
+        group::ExportedTree::from_bytes(&ratchet_tree.bytes).map_err(Into::into)
+    }
+}
+
+#[derive(Clone, Debug, uniffi::Record)]
+pub struct CommitOutput {
+    /// Commit message to send to other group members.
+    pub commit_message: Arc<Message>,
+
+    /// Welcome message to send to new group members. This will be
+    /// `None` if the commit did not add new members.
+    pub welcome_message: Option<Arc<Message>>,
+
+    /// Ratchet tree that can be sent out of band if the ratchet tree
+    /// extension is not used.
+    pub ratchet_tree: Option<RatchetTree>,
+
+    /// A group info that can be provided to new members in order to
+    /// enable external commit functionality.
+    pub group_info: Option<Arc<Message>>,
+    // TODO(mgeisler): decide if we should expose unused_proposals()
+    // as well.
+}
+
+impl TryFrom<mls_rs::group::CommitOutput> for CommitOutput {
+    type Error = Error;
+
+    fn try_from(commit_output: mls_rs::group::CommitOutput) -> Result<Self, Error> {
+        let commit_message = Arc::new(commit_output.commit_message.into());
+        let welcome_message = commit_output
+            .welcome_messages
+            .into_iter()
+            .next()
+            .map(|welcome_message| Arc::new(welcome_message.into()));
+        let ratchet_tree = commit_output
+            .ratchet_tree
+            .map(TryInto::try_into)
+            .transpose()?;
+        let group_info = commit_output
+            .external_commit_group_info
+            .map(|group_info| Arc::new(group_info.into()));
+
+        Ok(Self {
+            commit_message,
+            welcome_message,
+            ratchet_tree,
+            group_info,
+        })
+    }
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, uniffi::Object)]
+#[uniffi::export(Eq)]
+pub struct SigningIdentity {
+    inner: identity::SigningIdentity,
+}
+
+impl From<identity::SigningIdentity> for SigningIdentity {
+    fn from(inner: identity::SigningIdentity) -> Self {
+        Self { inner }
+    }
+}
+
+/// An MLS end-to-end encrypted group.
+///
+/// The group is used to send and process incoming messages and to
+/// add/remove users.
+///
+/// See [`mls_rs::Group`] for details.
+#[derive(Clone, uniffi::Object)]
+pub struct Group {
+    inner: Arc<Mutex<mls_rs::Group<UniFFIConfig>>>,
+}
+
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+#[cfg_attr(mls_build_async, maybe_async::must_be_async)]
+impl Group {
+    #[cfg(not(mls_build_async))]
+    fn inner(&self) -> std::sync::MutexGuard<'_, mls_rs::Group<UniFFIConfig>> {
+        self.inner.lock().unwrap()
+    }
+
+    #[cfg(mls_build_async)]
+    async fn inner(&self) -> tokio::sync::MutexGuard<'_, mls_rs::Group<UniFFIConfig>> {
+        self.inner.lock().await
+    }
+}
+
+/// Find the identity for the member with a given index.
+fn index_to_identity(
+    group: &mls_rs::Group<UniFFIConfig>,
+    index: u32,
+) -> Result<identity::SigningIdentity, Error> {
+    let member = group
+        .member_at_index(index)
+        .ok_or(MlsError::InvalidNodeIndex(index))?;
+    Ok(member.signing_identity)
+}
+
+/// Extract the basic credential identifier from a  from a key package.
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+#[cfg_attr(mls_build_async, maybe_async::must_be_async)]
+async fn signing_identity_to_identifier(
+    signing_identity: &identity::SigningIdentity,
+) -> Result<Vec<u8>, Error> {
+    let identifier = basic::BasicIdentityProvider::new()
+        .identity(signing_identity, &mls_rs::ExtensionList::new())
+        .await
+        .map_err(|err| err.into_any_error())?;
+    Ok(identifier)
+}
+
+#[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+#[cfg_attr(mls_build_async, maybe_async::must_be_async)]
+#[uniffi::export]
+impl Group {
+    /// Write the current state of the group to storage defined by
+    /// [`ClientConfig::group_state_storage`]
+    pub async fn write_to_storage(&self) -> Result<(), Error> {
+        let mut group = self.inner().await;
+        group.write_to_storage().await.map_err(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 `use_ratchet_tree_extension` is set to false in
+    /// `ClientConfig`.
+    pub async fn export_tree(&self) -> Result<RatchetTree, Error> {
+        let group = self.inner().await;
+        group.export_tree().try_into()
+    }
+
+    /// Perform a commit of received proposals (or an empty commit).
+    ///
+    /// TODO: ensure `path_required` is always set in
+    /// [`MlsRules::commit_options`](`mls_rs::MlsRules::commit_options`).
+    ///
+    /// Returns the resulting commit message. See
+    /// [`mls_rs::Group::commit`] for details.
+    pub async fn commit(&self) -> Result<CommitOutput, Error> {
+        let mut group = self.inner().await;
+        let commit_output = group.commit(Vec::new()).await?;
+        commit_output.try_into()
+    }
+
+    /// Commit the addition of one or more members.
+    ///
+    /// The members are representated by key packages. The result is
+    /// the welcome messages to send to the new members.
+    ///
+    /// See [`mls_rs::group::CommitBuilder::add_member`] for details.
+    pub async fn add_members(
+        &self,
+        key_packages: Vec<Arc<Message>>,
+    ) -> Result<CommitOutput, Error> {
+        let mut group = self.inner().await;
+        let mut commit_builder = group.commit_builder();
+        for key_package in key_packages {
+            commit_builder = commit_builder.add_member(arc_unwrap_or_clone(key_package).inner)?;
+        }
+        let commit_output = commit_builder.build().await?;
+        commit_output.try_into()
+    }
+
+    /// Propose to add one or more members to this group.
+    ///
+    /// The members are representated by key packages. The result is
+    /// the proposal messages to send to the group.
+    ///
+    /// See [`mls_rs::Group::propose_add`] for details.
+    pub async fn propose_add_members(
+        &self,
+        key_packages: Vec<Arc<Message>>,
+    ) -> Result<Vec<Arc<Message>>, Error> {
+        let mut group = self.inner().await;
+
+        let mut messages = Vec::with_capacity(key_packages.len());
+        for key_package in key_packages {
+            let key_package = arc_unwrap_or_clone(key_package);
+            let message = group.propose_add(key_package.inner, Vec::new()).await?;
+            messages.push(Arc::new(message.into()));
+        }
+
+        Ok(messages)
+    }
+
+    /// Propose and commit the removal of one or more members.
+    ///
+    /// The members are representated by their signing identities.
+    ///
+    /// See [`mls_rs::group::CommitBuilder::remove_member`] for details.
+    pub async fn remove_members(
+        &self,
+        signing_identities: &[Arc<SigningIdentity>],
+    ) -> Result<CommitOutput, Error> {
+        let mut group = self.inner().await;
+
+        // Find member indices
+        let mut member_indixes = Vec::with_capacity(signing_identities.len());
+        for signing_identity in signing_identities {
+            let identifier = signing_identity_to_identifier(&signing_identity.inner).await?;
+            let member = group.member_with_identity(&identifier).await?;
+            member_indixes.push(member.index);
+        }
+
+        let mut commit_builder = group.commit_builder();
+        for index in member_indixes {
+            commit_builder = commit_builder.remove_member(index)?;
+        }
+        let commit_output = commit_builder.build().await?;
+        commit_output.try_into()
+    }
+
+    /// Propose to remove one or more members from this group.
+    ///
+    /// The members are representated by their signing identities. The
+    /// result is the proposal messages to send to the group.
+    ///
+    /// See [`mls_rs::group::Group::propose_remove`] for details.
+    pub async fn propose_remove_members(
+        &self,
+        signing_identities: &[Arc<SigningIdentity>],
+    ) -> Result<Vec<Arc<Message>>, Error> {
+        let mut group = self.inner().await;
+
+        let mut messages = Vec::with_capacity(signing_identities.len());
+        for signing_identity in signing_identities {
+            let identifier = signing_identity_to_identifier(&signing_identity.inner).await?;
+            let member = group.member_with_identity(&identifier).await?;
+            let message = group.propose_remove(member.index, Vec::new()).await?;
+            messages.push(Arc::new(message.into()));
+        }
+
+        Ok(messages)
+    }
+
+    /// Encrypt an application message using the current group state.
+    pub async fn encrypt_application_message(&self, message: &[u8]) -> Result<Message, Error> {
+        let mut group = self.inner().await;
+        let mls_message = group
+            .encrypt_application_message(message, Vec::new())
+            .await?;
+        Ok(mls_message.into())
+    }
+
+    /// Process an inbound message for this group.
+    pub async fn process_incoming_message(
+        &self,
+        message: Arc<Message>,
+    ) -> Result<ReceivedMessage, Error> {
+        let message = arc_unwrap_or_clone(message);
+        let mut group = self.inner().await;
+        match group.process_incoming_message(message.inner).await? {
+            group::ReceivedMessage::ApplicationMessage(application_message) => {
+                let sender =
+                    Arc::new(index_to_identity(&group, application_message.sender_index)?.into());
+                let data = application_message.data().to_vec();
+                Ok(ReceivedMessage::ApplicationMessage { sender, data })
+            }
+            group::ReceivedMessage::Commit(commit_message) => {
+                let committer =
+                    Arc::new(index_to_identity(&group, commit_message.committer)?.into());
+                let roster_update = RosterUpdate::new(commit_message.state_update.roster_update());
+                Ok(ReceivedMessage::Commit {
+                    committer,
+                    roster_update,
+                })
+            }
+            group::ReceivedMessage::Proposal(proposal_message) => {
+                let sender = match proposal_message.sender {
+                    mls_rs::group::ProposalSender::Member(index) => {
+                        Arc::new(index_to_identity(&group, index)?.into())
+                    }
+                    _ => todo!("External and NewMember proposal senders are not supported"),
+                };
+                let proposal = Arc::new(proposal_message.proposal.into());
+                Ok(ReceivedMessage::ReceivedProposal { sender, proposal })
+            }
+            // TODO: group::ReceivedMessage::GroupInfo does not have any
+            // public methods (unless the "ffi" Cargo feature is set).
+            // So perhaps we don't need it?
+            group::ReceivedMessage::GroupInfo(_) => Ok(ReceivedMessage::GroupInfo),
+            group::ReceivedMessage::Welcome => Ok(ReceivedMessage::Welcome),
+            group::ReceivedMessage::KeyPackage(_) => Ok(ReceivedMessage::KeyPackage),
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    #[cfg(not(mls_build_async))]
+    use super::*;
+    #[cfg(not(mls_build_async))]
+    use crate::config::group_state::{EpochRecord, GroupStateStorage};
+    #[cfg(not(mls_build_async))]
+    use std::collections::HashMap;
+
+    #[test]
+    #[cfg(not(mls_build_async))]
+    fn test_simple_scenario() -> Result<(), Error> {
+        #[derive(Debug, Default)]
+        struct GroupStateData {
+            state: Vec<u8>,
+            epoch_data: Vec<EpochRecord>,
+        }
+
+        #[derive(Debug)]
+        struct CustomGroupStateStorage {
+            groups: Mutex<HashMap<Vec<u8>, GroupStateData>>,
+        }
+
+        impl CustomGroupStateStorage {
+            fn new() -> Self {
+                Self {
+                    groups: Mutex::new(HashMap::new()),
+                }
+            }
+
+            fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<Vec<u8>, GroupStateData>> {
+                self.groups.lock().unwrap()
+            }
+        }
+
+        impl GroupStateStorage for CustomGroupStateStorage {
+            fn state(&self, group_id: Vec<u8>) -> Result<Option<Vec<u8>>, Error> {
+                let groups = self.lock();
+                Ok(groups.get(&group_id).map(|group| group.state.clone()))
+            }
+
+            fn epoch(&self, group_id: Vec<u8>, epoch_id: u64) -> Result<Option<Vec<u8>>, Error> {
+                let groups = self.lock();
+                match groups.get(&group_id) {
+                    Some(group) => {
+                        let epoch_record =
+                            group.epoch_data.iter().find(|record| record.id == epoch_id);
+                        let data = epoch_record.map(|record| record.data.clone());
+                        Ok(data)
+                    }
+                    None => Ok(None),
+                }
+            }
+
+            fn write(
+                &self,
+                group_id: Vec<u8>,
+                group_state: Vec<u8>,
+                epoch_inserts: Vec<EpochRecord>,
+                epoch_updates: Vec<EpochRecord>,
+            ) -> Result<(), Error> {
+                let mut groups = self.lock();
+
+                let group = groups.entry(group_id).or_default();
+                group.state = group_state;
+                for insert in epoch_inserts {
+                    group.epoch_data.push(insert);
+                }
+
+                for update in epoch_updates {
+                    for epoch in group.epoch_data.iter_mut() {
+                        if epoch.id == update.id {
+                            epoch.data = update.data;
+                            break;
+                        }
+                    }
+                }
+
+                Ok(())
+            }
+
+            fn max_epoch_id(&self, group_id: Vec<u8>) -> Result<Option<u64>, Error> {
+                let groups = self.lock();
+                Ok(groups
+                    .get(&group_id)
+                    .and_then(|GroupStateData { epoch_data, .. }| epoch_data.last())
+                    .map(|last| last.id))
+            }
+        }
+
+        let alice_config = ClientConfig {
+            group_state_storage: Arc::new(CustomGroupStateStorage::new()),
+            ..Default::default()
+        };
+        let alice_keypair = generate_signature_keypair(CipherSuite::Curve25519Aes128)?;
+        let alice = Client::new(b"alice".to_vec(), alice_keypair, alice_config);
+
+        let bob_config = ClientConfig {
+            group_state_storage: Arc::new(CustomGroupStateStorage::new()),
+            ..Default::default()
+        };
+        let bob_keypair = generate_signature_keypair(CipherSuite::Curve25519Aes128)?;
+        let bob = Client::new(b"bob".to_vec(), bob_keypair, bob_config);
+
+        let alice_group = alice.create_group(None)?;
+        let bob_key_package = bob.generate_key_package_message()?;
+        let commit = alice_group.add_members(vec![Arc::new(bob_key_package)])?;
+        alice_group.process_incoming_message(commit.commit_message)?;
+
+        let bob_group = bob
+            .join_group(None, &commit.welcome_message.unwrap())?
+            .group;
+        let message = alice_group.encrypt_application_message(b"hello, bob")?;
+        let received_message = bob_group.process_incoming_message(Arc::new(message))?;
+
+        alice_group.write_to_storage()?;
+
+        let ReceivedMessage::ApplicationMessage { sender: _, data } = received_message else {
+            panic!("Wrong message type: {received_message:?}");
+        };
+        assert_eq!(data, b"hello, bob");
+
+        Ok(())
+    }
+
+    #[test]
+    #[cfg(not(mls_build_async))]
+    fn test_ratchet_tree_not_included() -> Result<(), Error> {
+        let alice_config = ClientConfig {
+            use_ratchet_tree_extension: true,
+            ..ClientConfig::default()
+        };
+
+        let alice_keypair = generate_signature_keypair(CipherSuite::Curve25519Aes128)?;
+        let alice = Client::new(b"alice".to_vec(), alice_keypair, alice_config);
+        let group = alice.create_group(None)?;
+
+        assert_eq!(group.commit()?.ratchet_tree, None);
+        Ok(())
+    }
+
+    #[test]
+    #[cfg(not(mls_build_async))]
+    fn test_ratchet_tree_included() -> Result<(), Error> {
+        let alice_config = ClientConfig {
+            use_ratchet_tree_extension: false,
+            ..ClientConfig::default()
+        };
+
+        let alice_keypair = generate_signature_keypair(CipherSuite::Curve25519Aes128)?;
+        let alice = Client::new(b"alice".to_vec(), alice_keypair, alice_config);
+        let group = alice.create_group(None)?;
+
+        let ratchet_tree: group::ExportedTree =
+            group.commit()?.ratchet_tree.unwrap().try_into().unwrap();
+        group.inner().apply_pending_commit()?;
+
+        assert_eq!(ratchet_tree, group.inner().export_tree());
+        Ok(())
+    }
+}
diff --git a/tests/client_config_default_async.py b/tests/client_config_default_async.py
new file mode 100644
index 0000000..2c0cc8d
--- /dev/null
+++ b/tests/client_config_default_async.py
@@ -0,0 +1,15 @@
+import asyncio
+
+from mls_rs_uniffi import Client, CipherSuite, generate_signature_keypair, client_config_default
+
+
+async def scenario():
+    client_config = client_config_default()
+    key = await generate_signature_keypair(CipherSuite.CURVE25519_AES128)
+    alice = Client(b'alice', key, client_config)
+
+    group = await alice.create_group(None)
+    await group.write_to_storage()
+
+
+asyncio.run(scenario())
diff --git a/tests/client_config_default_sync.py b/tests/client_config_default_sync.py
new file mode 100644
index 0000000..e79973a
--- /dev/null
+++ b/tests/client_config_default_sync.py
@@ -0,0 +1,8 @@
+from mls_rs_uniffi import Client, CipherSuite, generate_signature_keypair, client_config_default
+
+client_config = client_config_default()
+key = generate_signature_keypair(CipherSuite.CURVE25519_AES128)
+alice = Client(b'alice', key, client_config)
+
+group = alice.create_group(None)
+group.write_to_storage()
diff --git a/tests/custom_storage_sync.py b/tests/custom_storage_sync.py
new file mode 100644
index 0000000..2c3d3de
--- /dev/null
+++ b/tests/custom_storage_sync.py
@@ -0,0 +1,87 @@
+from dataclasses import dataclass, field
+
+from mls_rs_uniffi import CipherSuite, generate_signature_keypair, Client, \
+    GroupStateStorage, EpochRecord, ClientConfig, ProtocolVersion
+
+
+@dataclass
+class GroupStateData:
+    state: bytes
+    epoch_data: list[EpochRecord] = field(default_factory=list)
+
+
+class PythonGroupStateStorage(GroupStateStorage):
+
+    def __init__(self):
+        self.groups: dict[str, GroupStateData] = {}
+
+    def state(self, group_id: bytes):
+        group = self.groups.get(group_id.hex())
+        if group == None:
+            return None
+
+        return group.state
+
+    def epoch(self, group_id: bytes, epoch_id: int):
+        group = self.groups.get(group_id.hex())
+        if group == None:
+            return None
+
+        for epoch in group.epoch_data:
+            if epoch.id == epoch_id:
+                return epoch
+
+        return None
+
+    def write(self, group_id: bytes, group_state: bytes,
+              epoch_inserts: list[EpochRecord],
+              epoch_updates: list[EpochRecord]):
+        if group_id.hex() not in self.groups:
+            self.groups[group_id.hex()] = GroupStateData(group_state)
+        group = self.groups[group_id.hex()]
+
+        for insert in epoch_inserts:
+            group.epoch_data.append(insert)
+
+        for update in epoch_updates:
+            for i in range(len(group.epoch_data)):
+                if group.epoch_data[i].id == update.id:
+                    group.epoch_data[i] = update
+
+    def max_epoch_id(self, group_id: bytes):
+        group = self.groups.get(group_id.hex())
+        if group == None:
+            return None
+
+        last = group.epoch_data.last()
+
+        if last == None:
+            return None
+
+        return last.id
+
+
+group_state_storage = PythonGroupStateStorage()
+client_config = ClientConfig(group_state_storage=group_state_storage,
+                             use_ratchet_tree_extension=True)
+
+key = generate_signature_keypair(CipherSuite.CURVE25519_AES128)
+alice = Client(b'alice', key, client_config)
+
+key = generate_signature_keypair(CipherSuite.CURVE25519_AES128)
+bob = Client(b'bob', key, client_config)
+
+alice = alice.create_group(None)
+message = bob.generate_key_package_message()
+
+output = alice.add_members([message])
+alice.process_incoming_message(output.commit_message)
+bob = bob.join_group(None, output.welcome_message).group
+
+msg = alice.encrypt_application_message(b'hello, bob')
+output = bob.process_incoming_message(msg)
+
+alice.write_to_storage()
+
+assert output.data == b'hello, bob'
+assert len(group_state_storage.groups) == 1
diff --git a/tests/generate_signature_keypair_async.py b/tests/generate_signature_keypair_async.py
new file mode 100644
index 0000000..76a29a3
--- /dev/null
+++ b/tests/generate_signature_keypair_async.py
@@ -0,0 +1,11 @@
+from mls_rs_uniffi import CipherSuite, generate_signature_keypair
+import asyncio
+
+
+async def scenario():
+    signature_keypair = await generate_signature_keypair(
+        CipherSuite.CURVE25519_AES128)
+    assert signature_keypair.cipher_suite == CipherSuite.CURVE25519_AES128
+
+
+asyncio.run(scenario())
diff --git a/tests/generate_signature_keypair_sync.py b/tests/generate_signature_keypair_sync.py
new file mode 100644
index 0000000..6a82aad
--- /dev/null
+++ b/tests/generate_signature_keypair_sync.py
@@ -0,0 +1,4 @@
+from mls_rs_uniffi import CipherSuite, generate_signature_keypair
+
+signature_keypair = generate_signature_keypair(CipherSuite.CURVE25519_AES128)
+assert signature_keypair.cipher_suite == CipherSuite.CURVE25519_AES128
\ No newline at end of file
diff --git a/tests/ratchet_tree_async.py b/tests/ratchet_tree_async.py
new file mode 100644
index 0000000..b707e26
--- /dev/null
+++ b/tests/ratchet_tree_async.py
@@ -0,0 +1,20 @@
+import asyncio
+
+from mls_rs_uniffi import CipherSuite, generate_signature_keypair, Client, \
+    client_config_default
+
+
+async def scenario():
+    client_config = client_config_default()
+    client_config.use_ratchet_tree_extension = False
+
+    key = await generate_signature_keypair(CipherSuite.CURVE25519_AES128)
+    alice = Client(b'alice', key, client_config)
+
+    group = await alice.create_group(None)
+    commit = await group.commit()
+
+    assert commit.ratchet_tree is not None
+
+
+asyncio.run(scenario())
diff --git a/tests/ratchet_tree_sync.py b/tests/ratchet_tree_sync.py
new file mode 100644
index 0000000..677225d
--- /dev/null
+++ b/tests/ratchet_tree_sync.py
@@ -0,0 +1,13 @@
+from mls_rs_uniffi import CipherSuite, generate_signature_keypair, Client, \
+    client_config_default
+
+client_config = client_config_default()
+client_config.use_ratchet_tree_extension = False
+
+key = generate_signature_keypair(CipherSuite.CURVE25519_AES128)
+alice = Client(b'alice', key, client_config)
+
+group = alice.create_group(None)
+commit = group.commit()
+
+assert commit.ratchet_tree is not None
diff --git a/tests/roster_update_sync.py b/tests/roster_update_sync.py
new file mode 100644
index 0000000..1b88681
--- /dev/null
+++ b/tests/roster_update_sync.py
@@ -0,0 +1,22 @@
+from mls_rs_uniffi import Client, CipherSuite, generate_signature_keypair, client_config_default
+
+client_config = client_config_default()
+alice = Client(b'alice', generate_signature_keypair(CipherSuite.CURVE25519_AES128), client_config)
+bob = Client(b'bob', generate_signature_keypair(CipherSuite.CURVE25519_AES128), client_config)
+carla = Client(b'carla', generate_signature_keypair(CipherSuite.CURVE25519_AES128), client_config)
+
+# Alice creates a group and adds Bob.
+alice_group = alice.create_group(None)
+output = alice_group.add_members([bob.generate_key_package_message()])
+alice_group.process_incoming_message(output.commit_message)
+
+# Bob join the group and adds Carla.
+bob_group = bob.join_group(None, output.welcome_message).group
+output = bob_group.add_members([carla.generate_key_package_message()])
+bob_group.process_incoming_message(output.commit_message)
+
+# Alice learns that Carla has been added to the group.
+received = alice_group.process_incoming_message(output.commit_message)
+assert received.roster_update.added == [carla.signing_identity()]
+assert received.roster_update.removed == []
+assert received.roster_update.updated == []
diff --git a/tests/scenarios.rs b/tests/scenarios.rs
new file mode 100644
index 0000000..47e5e93
--- /dev/null
+++ b/tests/scenarios.rs
@@ -0,0 +1,53 @@
+/// Run sync and async Python scenarios.
+///
+/// The Python scripts are given as identifiers, relative to this
+/// file. They can be `None` if the sync or async test variant does
+/// not exist.
+///
+/// The test script can use `import mls_rs_uniffi` to get access to
+/// the Python bindings.
+macro_rules! generate_python_tests {
+    ($sync_scenario:ident, None) => {
+        #[cfg(not(mls_build_async))]
+        generate_python_tests!($sync_scenario);
+    };
+
+    (None, $async_scenario:ident) => {
+        #[cfg(mls_build_async)]
+        generate_python_tests!($async_scenario);
+    };
+
+    ($sync_scenario:ident, $async_scenario:ident) => {
+        #[cfg(not(mls_build_async))]
+        generate_python_tests!($sync_scenario);
+
+        #[cfg(mls_build_async)]
+        generate_python_tests!($async_scenario);
+    };
+
+    ($scenario:ident) => {
+        #[test]
+        fn $scenario() -> Result<(), Box<dyn std::error::Error>> {
+            let target_dir = env!("CARGO_TARGET_TMPDIR");
+            let script_path = format!("tests/{}.py", stringify!($scenario));
+            uniffi_bindgen::bindings::python::run_script(
+                &target_dir,
+                "mls-rs-uniffi",
+                &script_path,
+                vec![],
+                &uniffi_bindgen::bindings::RunScriptOptions::default(),
+            )
+            .map_err(Into::into)
+        }
+    };
+}
+
+generate_python_tests!(
+    generate_signature_keypair_sync,
+    generate_signature_keypair_async
+);
+generate_python_tests!(client_config_default_sync, client_config_default_async);
+generate_python_tests!(custom_storage_sync, None);
+generate_python_tests!(simple_scenario_sync, simple_scenario_async);
+generate_python_tests!(ratchet_tree_sync, ratchet_tree_async);
+generate_python_tests!(roster_update_sync, None);
diff --git a/tests/simple_scenario_async.py b/tests/simple_scenario_async.py
new file mode 100644
index 0000000..610efe3
--- /dev/null
+++ b/tests/simple_scenario_async.py
@@ -0,0 +1,31 @@
+import asyncio
+
+from mls_rs_uniffi import CipherSuite, generate_signature_keypair, Client, \
+    client_config_default
+
+
+async def scenario():
+    client_config = client_config_default()
+
+    key = await generate_signature_keypair(CipherSuite.CURVE25519_AES128)
+    alice = Client(b'alice', key, client_config)
+
+    key = await generate_signature_keypair(CipherSuite.CURVE25519_AES128)
+    bob = Client(b'bob', key, client_config)
+
+    alice = await alice.create_group(None)
+    message = await bob.generate_key_package_message()
+
+    commit = await alice.add_members([message])
+    await alice.process_incoming_message(commit.commit_message)
+    bob = (await bob.join_group(None, commit.welcome_message)).group
+
+    msg = await alice.encrypt_application_message(b'hello, bob')
+    output = await bob.process_incoming_message(msg)
+
+    await alice.write_to_storage()
+
+    assert output.data == b'hello, bob'
+
+
+asyncio.run(scenario())
diff --git a/tests/simple_scenario_sync.py b/tests/simple_scenario_sync.py
new file mode 100644
index 0000000..9c04380
--- /dev/null
+++ b/tests/simple_scenario_sync.py
@@ -0,0 +1,24 @@
+from mls_rs_uniffi import CipherSuite, generate_signature_keypair, Client, \
+    client_config_default
+
+client_config = client_config_default()
+
+key = generate_signature_keypair(CipherSuite.CURVE25519_AES128)
+alice = Client(b'alice', key, client_config)
+
+key = generate_signature_keypair(CipherSuite.CURVE25519_AES128)
+bob = Client(b'bob', key, client_config)
+
+alice = alice.create_group(None)
+message = bob.generate_key_package_message()
+
+commit = alice.add_members([message])
+alice.process_incoming_message(commit.commit_message)
+bob = bob.join_group(None, commit.welcome_message).group
+
+msg = alice.encrypt_application_message(b'hello, bob')
+output = bob.process_incoming_message(msg)
+
+alice.write_to_storage()
+
+assert output.data == b'hello, bob'