The rise of Kotlin Multiplatform (KMP) for mobile development makes it tempting to centralize business logic in shared Kotlin, but for performance-sensitive and security-critical components it’s often best to Offload Crypto to Rust in Kotlin Multiplatform to gain memory-safety, speed, and mature cryptographic crates.
Why offload cryptography to Rust?
Rust offers low-level control, deterministic performance, and strong memory-safety guarantees without a garbage collector — a compelling combination for cryptography. Well-maintained Rust crates implement constant-time algorithms, provide audited primitives (or bindings to native libraries like BoringSSL) and give access to advanced features (zeroing memory, secure random sources) that are harder to guarantee on managed runtimes. In KMP projects, delegating heavy crypto (encryption, signing, key derivation, bulk hashing) to Rust reduces battery and CPU usage on mobile devices while centralizing auditable crypto logic in a single native library.
Deciding what to offload
- Offload: heavy or sensitive primitives — AES-GCM/AES-CTR, ChaCha20-Poly1305, HKDF, scrypt/Argon2, Ed25519/SECP256k1 signing, and large batched hashing.
- Keep in Kotlin: orchestration, key management UX, and high-level policies (when they call into Rust for the actual operations).
- Hybrid: expose small, well-defined APIs from Rust (encrypt/decrypt/sign/verify) and keep session logic in Kotlin.
FFI patterns for Kotlin Multiplatform
There are two practical FFI patterns for KMP when integrating Rust across Android and iOS:
1. C-ABI + Kotlin/Native cinterop (recommended for iOS and shared code)
Compile Rust as a C-compatible static or dynamic library (cdylib or staticlib). Use cbindgen to generate a C header and Kotlin/Native’s cinterop tool to produce Kotlin bindings for iOS and other native targets.
// Rust: expose a simple C ABI
#[no_mangle]
pub extern "C" fn encrypt(input: *const u8, len: usize, out: *mut u8) -> i32 { /*...*/ }
This pattern keeps the boundary simple (C types) and integrates cleanly with KMP’s iOS target via the produced Kotlin stubs.
2. JNI (Android) or thin C shim (Android + iOS)
On Android, either call Rust via JNI (Rust <-> JNI glue) or produce a native library and write a thin C shim that maps to the JNI layer Kotlin expects. Using cargo-ndk simplifies multi-ABI builds for ARM/ARM64/x86_64. The thin-shim approach standardizes the ABI and makes the same header usable on both platforms when feasible.
FFI design patterns
- Thin functional API: expose pure functions (encrypt, decrypt) with buffers — simplest and easiest to reason about.
- Opaque handles: allocate Rust-managed context objects and return opaque pointers/handles to Kotlin, reducing repeated setup cost for streaming operations.
- Error model: return integer error codes or a small error struct; avoid throwing exceptions across FFI boundaries.
- Memory ownership: clearly document whether the caller or callee owns buffers; prefer Rust allocating output into caller-provided buffers to avoid unpredictable GC interactions.
Performance benchmarking methodology
Benchmarks should measure realistic patterns: small-chunk operations (e.g., per-message AEAD) and bulk processing (e.g., file encryption). Use representative payload sizes (256 B, 4 KB, 1 MB), warm-up runs, and multiple device types (low-end Android, flagship Android, iPhone SE, iPhone Pro). For repeatability, use native microbenchmarks in Rust (cargo bench or criterion) and cross-language benchmarks that call the FFI layer from Kotlin to include call overhead.
Expected results and bottlenecks
- Throughput for CPU-bound primitives usually improves by 2–10x compared to pure Kotlin/JVM implementations leveraging managed allocations.
- Short-lived synchronous calls pay an FFI overhead; batch up operations or use streaming/handles for small messages to amortize boundary costs.
- Memory allocations and copies across the FFI are common bottlenecks — pre-allocated buffers and zero-copy techniques minimize overhead.
Packaging strategies for distribution
Packaging must produce platform-native artifacts usable by KMP’s Gradle pipeline and iOS build system.
Android
- Use cargo-ndk to build .so libraries for each ABI and then include them in an Android AAR. Configure Gradle to package native libs under src/main/jniLibs/abi/.
- Alternatively, create a Kotlin JVM module that loads the .so via System.loadLibrary and exposes JNI wrappers to shared code.
iOS
- Build an XCFramework that bundles slices for arm64 and simulator, expose a C header (cbindgen), and configure CocoaPods or the Xcode project to link the framework.
- Kotlin/Native cinterop consumes the generated header; distribute the XCFramework alongside KMP artifacts or via a binary Git release.
Cross-platform distribution
- Host prebuilt artifacts in a Maven repository for Android and push XCFrameworks to GitHub Releases or a CocoaPods repo for iOS.
- Automate multi-ABI builds with CI (GitHub Actions matrix), produce checksums and release notes, and sign artifacts where required.
Security best practices
- Use vetted crates (ring, rust-crypto, or wrappers around BoringSSL) and keep dependencies updated.
- Use zeroize and secrecy types for key material to ensure memory is zeroed when dropped.
- Prefer constant-time implementations for secret comparisons and avoid branching on secret data.
- Minimize surface area: expose a small, well-audited API and avoid exposing raw primitives that let callers misuse the library.
- Fuzzing and audits: integrate cargo-fuzz, run fuzzers in CI, and consider periodic third-party audits for critical primitives.
Testing and CI
CI should build and test Rust crates and Kotlin multiplatform projects in a matrix that includes Android API levels and iOS simulator/device slices. Add cross-language integration tests that exercise the full FFI path and include property-based tests for cryptographic correctness. Automate artifact packaging and optionally sign binaries before releasing.
Developer ergonomics and DX
- Document the public FFI signature, ownership rules, and threading guarantees clearly in the repo README.
- Provide example Kotlin wrappers that convert Kotlin ByteArray to native buffers, handling lifetimes and errors transparently.
- Include a small sample app for each platform that demonstrates encryption, key import/export, and verification workflows.
Offloading crypto to Rust in Kotlin Multiplatform is a pragmatic, high-impact move for mobile apps that need performance and security. By designing minimal, well-documented FFI boundaries, batching operations to reduce call overhead, and packaging native artifacts consistently, teams can achieve faster cryptographic operations and a single audited implementation shared across Android and iOS.
Conclusion: Move the heavy, security-sensitive crypto into Rust and keep the KMP layer focused on orchestration and UX — the resulting system is faster, safer, and more auditable. Ready to prototype your first Rust-backed KMP crypto module? Try building a small AEAD module and measure the difference on a real device today.
