Skip to main content
CoreCrypto is structured as four discrete layers, each with a single responsibility. Understanding this stack is the key to understanding every API surface exposed by the library.
┌──────────────────────────────────────────┐
│              CoreCryptoFFI               │  UniFFI (iOS/Android) · wasm-bindgen (JS)
├──────────────────────────────────────────┤
│               CoreCrypto                │  Unified MLS + Proteus API
├──────────────────────────────────────────┤
│   Session (MLS)  │  ProteusCentral      │  Protocol-specific state
├──────────────────────────────────────────┤
│      MlsProvider (RustCrypto + KS)      │  OpenMLS crypto-provider glue
├──────────────────────────────────────────┤
│              Keystore                    │  SQLCipher (native) · IndexedDB (WASM)
└──────────────────────────────────────────┘
The keystore is the foundation of the stack. Its purpose is to securely store all client keying material on-device so that key material survives process restarts and is never written to disk in plaintext.On native targets (Linux, macOS, Windows, iOS, Android) the keystore uses SQLCipher — an encrypted SQLite fork that applies AES-256-CBC encryption on every 4096-byte page, with each page receiving a freshly generated random IV and an HMAC-SHA-512 authentication tag.On WASM (browsers and Electron) the backing store is the browser’s IndexedDB (via the idb crate). Because IndexedDB has no native encryption layer, CoreCrypto applies AES-256-GCM value-level encryption using RustCrypto’s aes-gcm crate before any record is written.The two central types exported from the keystore crate are:
  • Database — the connection handle (cheap to clone via internal Arc)
  • DatabaseKey — a 32-byte AES key, zeroed on drop via zeroize
use core_crypto_keystore::{ConnectionType, Database, DatabaseKey};

let key = DatabaseKey::generate(); // CSPRNG-generated 32 bytes
let db = Database::open(ConnectionType::Persistent("./my.db"), &key).await?;
MlsProvider (MlsCryptoProvider internally) bridges the keystore to OpenMLS by implementing the OpenMlsCryptoProvider trait. OpenMLS calls into this provider for every cryptographic operation and every key-material read or write.The provider composes two sub-providers:
  • CryptoRustCrypto, providing the underlying primitives (HKDF, AES-GCM, Ed25519, P-256, P-384, X25519)
  • Key store — the Database instance described above
Applications never interact with MlsProvider directly; it is created internally when TransactionContext::mls_init is called.
CoreCrypto (crypto/src/lib.rs) is the top-level owned handle that an application creates once and keeps for the lifetime of the process.
pub struct CoreCrypto {
    database: Database,
    pki_environment: Arc<RwLock<Option<PkiEnvironment>>>,
    mls: Arc<RwLock<Option<mls::session::Session<Database>>>>,
    proteus: Arc<Mutex<Option<proteus::ProteusCentral>>>,  // only with `proteus` feature
}
Key properties:
  • Cheap to clone — all interior state is stored behind Arc, so cloning is O(1) reference-count increments.
  • Protocol agnostic — holds both an optional MLS Session and an optional ProteusCentral. Either or both can be initialised at runtime.
  • Read-only by designCoreCrypto itself exposes no mutating methods. All mutations must go through a TransactionContext (see below).
Construct it with a Database handle:
let cc = CoreCrypto::new(db);
All state-mutating operations in CoreCrypto — creating conversations, adding members, generating key packages, initialising Proteus — must be performed through a TransactionContext.A transaction is opened with CoreCrypto::new_transaction() and closed with either finish() (commit) or abort() (rollback). The context buffers all keystore writes in memory; they are flushed to the underlying database in a single atomic operation when finish() is called.
let ctx = cc.new_transaction().await?;
ctx.mls_init(client_id, transport).await?;
ctx.finish().await?;
Once finish() or abort() has been called, the context transitions to an Invalid state and further calls return Error::InvalidTransactionContext. This design ensures that UniFFI bindings (which cannot enforce Rust ownership rules across the FFI boundary) still get deterministic transaction semantics.The TransactionContext holds:
  • A shared reference to the mls_session Arc<RwLock<Option<Session>>>
  • A local mls_groups GroupStore<MlsConversation> for in-flight changes
  • A shared reference to proteus_central (feature-gated)
CoreCrypto uses four logical entities that map directly onto MLS RFC 9420 terminology:
CoreCrypto termMLS equivalentDescription
Central (Session)ClientThe entry point once the library is initialised. Owns local keying material. One per user-device.
ClientClientThe local device identity that can produce key material (key packages, commits).
MemberMemberA remote participant in a conversation. Cannot produce local keying material.
ConversationGroupAn MLS group (or a set of Proteus pairwise sessions) in which the local Client participates.
On the MLS side, Session (previously MlsCentral) acts as the Central — it finds or creates the local client identity and restores persisted conversation groups on startup.
The FFI layer exposes CoreCrypto to non-Rust consumers without exposing any Rust types directly.Two separate strategies are used depending on the target:iOS and Android use UniFFI to generate Kotlin and Swift bindings from a UDL (UniFFI Definition Language) file. UniFFI generates type-safe wrappers, async support, and error enumerations automatically.Web / Electron targets compile CoreCrypto to WebAssembly. The wasm-bindgen crate generates a JavaScript/TypeScript package from #[wasm_bindgen] annotations on the Rust types. Async functions are exposed as Promise-returning methods.Both strategies present the same logical API surface — the same conversation lifecycle, the same error codes — so application-level code can stay platform- agnostic with thin adapter layers.
The UniFFI path (#[cfg(not(target_os = "unknown"))]) and the wasm-bindgen path (#[cfg(target_os = "unknown")]) share the same core Rust logic but differ in how async traits are expressed: native targets use async_trait with Send bounds, while WASM targets use async_trait(?Send) because browser APIs are inherently single-threaded and values cannot be moved across thread boundaries.

Build docs developers (and LLMs) love