Rust + NDK on Android, Part 12 of 15.This series teaches Android developers how to ship Rust in production apps, no prior Rust required. Hub: Rust on Android: How to Use It, How to Debug It, and Why It's Worth It. Previous: Heavy Parsing with Serde: Offloading JSON and CSV Crunching to Rust. Next: Binary Size and the 16KB Page Requirement: Optimizing Rust .so Files for Release (coming soon).
The right amount of cryptographic algorithm code to write yourself for an Android app is zero, and Rust does not change that rule. The case for Rust crypto is calling audited implementations, ring or the RustCrypto crates, from a language that cannot corrupt the memory around them, while Android Keystore keeps ownership of the keys. This part builds the rust/cryptobox module: streamed BLAKE3 hashing, XChaCha20-Poly1305 file encryption, and constant-time comparison for the secrets that need it.
This is not a cryptography course. The scope is the three jobs apps actually have: prove a file is intact, keep a file confidential, and never leak a secret while checking one. It assumes the toolchain from Part 1, the byte-crossing cost model from Part 6, and the exception mapping from Part 8.
Key Takeaways
- Crypto code is the worst place for a memory bug: Google measured 0.2 memory safety vulnerabilities per million lines of Rust in Android against roughly 1,000 for its C and C++ (Google, 2025).
- ring wraps BoringSSL-derived primitives in C and assembly behind a small Rust API; RustCrypto is a family of pure-Rust crates, one primitive each, and its AEAD crates carry an NCC Group audit with no significant findings (docs.rs).
- Hardware-backed Android Keystore keys never enter your app’s process, so Rust can never use them directly. The division of labor is envelope encryption: the Keystore key wraps a data key, and only the data key crosses into Rust.
- Never compare secrets with
==. The AEAD crates verify their tags in constant time internally, and the subtle crate covers every comparison you still write yourself.
Why crypto is where memory safety pays the most
Heartbleed was not a cryptographic flaw. It was a missing bounds check in OpenSSL’s heartbeat handler, a memory safety bug that let anyone read the memory next to the crypto code, private keys included (The Heartbleed Bug, 2014). Crypto code processes attacker input beside the most valuable bytes in the process; an over-read there is a disclosure, not a crash.
Crypto is the poster child for the numbers in the series’ anchor post: 0.2 memory safety vulnerabilities per million lines of Rust in Android, against roughly 1,000 for Google’s C and C++ history (Google, Rust in Android: move fast and fix things, 2025). The production table there has a theme, Signal, Wire, 1Password, Bitwarden: when cryptographic correctness is the product, the crypto core moved to Rust first.
One honest boundary before the code. Memory safety removes a whole class of implementation bugs and does nothing for design bugs. A memory-safe program that reuses an AES-GCM nonce has still lost its authentication key, because unique IVs are a hard requirement of the mode itself (NIST SP 800-38D). So this post sticks to audited crates, boring constructions, and Keystore-owned keys.
Choosing Rust crypto crates for Android: ring or RustCrypto
Two names dominate the Rust crypto search results. ring describes itself as safe, fast, small crypto using Rust with BoringSSL’s cryptography primitives: a deliberately small API over a core inherited from Google’s TLS stack, much of it C and hand-written assembly. That pedigree is its strength; the cost on Android is that every ABI you ship now compiles C and assembly too. cargo-ndk wires the C compiler to the NDK toolchain for you, as Part 1 set up, but it is one more thing to diagnose when a single ABI misbehaves.
RustCrypto is the other model: an organization publishing one small pure-Rust crate per primitive, sha2, aes-gcm, chacha20poly1305. Pure Rust cross-compiles like any other crate in this series, and your dependency tree contains exactly the algorithms you use. The audit status is public: aes-gcm and chacha20poly1305 received a security audit by NCC Group with no significant findings (docs.rs), and the aes crate uses the ARMv8 AES instructions on 64-bit Android hardware.
For hashing, the blake3 crate is the official implementation from the BLAKE3 team, with NEON acceleration on 64-bit ARM. My rule for this module and most app work: RustCrypto plus blake3, which keeps the build pure Rust and the size accounting honest. Reach for ring when you are already in its ecosystem, typically for TLS or its signature and key-agreement primitives.
Hashing files with BLAKE3, streamed from Rust
The module’s manifest pulls five small dependencies. This is rust/cryptobox/Cargo.toml in full:
[package]
name = "cryptobox"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
jni = "0.21"
blake3 = "1"
chacha20poly1305 = "0.10"
subtle = "2"
zeroize = "1"
The hashing API takes a file path, not the file’s bytes: the chunky-call principle from Part 6 applied to crypto. Crossing JNI once with a short string beats marshalling a hundred megabytes, and streaming in Rust keeps memory flat whatever the file size. Here are the imports and the hashing half of rust/cryptobox/src/lib.rs:
use std::fs::File;
use std::io::{BufReader, Read};
use chacha20poly1305::aead::{Aead, AeadCore, KeyInit, OsRng};
use chacha20poly1305::{XChaCha20Poly1305, XNonce};
use jni::objects::{JByteArray, JClass, JString};
use jni::sys::{jbyteArray, jstring};
use jni::JNIEnv;
use zeroize::Zeroize;
const NONCE_LEN: usize = 24;
fn hash_file(path: &str) -> std::io::Result<String> {
let file = File::open(path)?;
let mut reader = BufReader::new(file);
let mut hasher = blake3::Hasher::new();
let mut buf = vec![0u8; 64 * 1024];
loop {
let read = reader.read(&mut buf)?;
if read == 0 {
break;
}
hasher.update(&buf[..read]);
}
Ok(hasher.finalize().to_hex().to_string())
}
#[no_mangle]
pub extern "system" fn Java_dev_davthecoder_rustseries_CryptoBox_nativeHashFile(
mut env: JNIEnv,
_class: JClass,
path: JString,
) -> jstring {
let path: String = match env.get_string(&path) {
Ok(s) => s.into(),
Err(_) => return std::ptr::null_mut(),
};
match hash_file(&path) {
Ok(hex) => env
.new_string(hex)
.expect("hex output is valid UTF-8")
.into_raw(),
Err(err) => {
let _ = env.throw_new("java/io/IOException", err.to_string());
std::ptr::null_mut()
}
}
}
A BufReader and 64 kilobytes at a time are plenty for a demo, and an I/O failure surfaces in Kotlin as an ordinary IOException, the mapping pattern from Part 8. For more throughput, Hasher::update_mmap_rayon behind the mmap and rayon features memory-maps the file and hashes it on all cores (docs.rs/blake3); I left it out to keep the dependency list short.
Sealing files with XChaCha20-Poly1305
The module encrypts with an AEAD, authenticated encryption with associated data: one operation that encrypts and authenticates, so tampered ciphertext fails to decrypt instead of decrypting into garbage. The mainstream choices are AES-256-GCM and ChaCha20-Poly1305 (RFC 8439). I picked the extended-nonce XChaCha20-Poly1305 for one reason: its 24-byte nonce is safe to generate randomly for every message, and that is exactly how the crate documents it should be used (docs.rs/chacha20poly1305). That deletes nonce bookkeeping, the classic AEAD footgun, from the design.
The core is two functions. The sealed format is the random nonce, then the ciphertext, which ends with the 16-byte tag. This continues rust/cryptobox/src/lib.rs:
fn seal(key: &[u8], plaintext: &[u8]) -> Result<Vec<u8>, String> {
let cipher = XChaCha20Poly1305::new_from_slice(key)
.map_err(|_| "key must be exactly 32 bytes".to_string())?;
let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng);
let ciphertext = cipher
.encrypt(&nonce, plaintext)
.map_err(|_| "encryption failed".to_string())?;
let mut out = Vec::with_capacity(NONCE_LEN + ciphertext.len());
out.extend_from_slice(nonce.as_slice());
out.extend_from_slice(&ciphertext);
Ok(out)
}
fn open(key: &[u8], sealed: &[u8]) -> Result<Vec<u8>, String> {
if sealed.len() < NONCE_LEN {
return Err("input shorter than the nonce".to_string());
}
let cipher = XChaCha20Poly1305::new_from_slice(key)
.map_err(|_| "key must be exactly 32 bytes".to_string())?;
let (nonce, ciphertext) = sealed.split_at(NONCE_LEN);
cipher
.decrypt(XNonce::from_slice(nonce), ciphertext)
.map_err(|_| "decryption failed: wrong key or modified data".to_string())
}
Notice what open never does: it never touches the tag. The crate verifies it internally, in constant time, and returns one deliberately opaque error for every failure mode. That opacity is a feature: decrypt APIs that explained exactly why they failed are how padding oracle attacks were born.
The JNI boundary is one shared wrapper, because sealing and opening have the same shape: two byte arrays in, one out, exception on failure. The key bytes are wiped with zeroize the moment the operation finishes. These functions complete rust/cryptobox/src/lib.rs:
fn run_aead(
env: &mut JNIEnv,
key: JByteArray,
input: JByteArray,
op: fn(&[u8], &[u8]) -> Result<Vec<u8>, String>,
) -> jbyteArray {
let mut key_bytes = match env.convert_byte_array(&key) {
Ok(bytes) => bytes,
Err(_) => return std::ptr::null_mut(),
};
let input_bytes = match env.convert_byte_array(&input) {
Ok(bytes) => bytes,
Err(_) => {
key_bytes.zeroize();
return std::ptr::null_mut();
}
};
let result = op(&key_bytes, &input_bytes);
key_bytes.zeroize();
match result {
Ok(out) => match env.byte_array_from_slice(&out) {
Ok(arr) => arr.into_raw(),
Err(_) => std::ptr::null_mut(),
},
Err(message) => {
let _ = env.throw_new("java/security/GeneralSecurityException", message);
std::ptr::null_mut()
}
}
}
#[no_mangle]
pub extern "system" fn Java_dev_davthecoder_rustseries_CryptoBox_nativeSeal(
mut env: JNIEnv,
_class: JClass,
key: JByteArray,
plaintext: JByteArray,
) -> jbyteArray {
run_aead(&mut env, key, plaintext, seal)
}
#[no_mangle]
pub extern "system" fn Java_dev_davthecoder_rustseries_CryptoBox_nativeOpen(
mut env: JNIEnv,
_class: JClass,
key: JByteArray,
sealed: JByteArray,
) -> jbyteArray {
run_aead(&mut env, key, sealed, open)
}
One more constant-time note, because audits keep catching it. The AEAD handled its own tag, but any secret you compare yourself, a stored MAC, an API token, a derived key, must not go through ==. Slice equality returns at the first differing byte, and that timing is measurable. The subtle crate exists for this, also in rust/cryptobox/src/lib.rs:
use subtle::ConstantTimeEq;
/// Constant-time equality for secrets: MACs, tokens, derived keys.
pub fn secrets_equal(a: &[u8], b: &[u8]) -> bool {
a.ct_eq(b).into()
}
Android Keystore still owns the keys
Where do the 32 key bytes come from? Not from anywhere Rust can reach. Android Keystore’s security model is that key material for hardware-backed keys never enters the application process; the key lives in the TEE or StrongBox and only operation requests cross over (Android Developers, Android Keystore system). That constraint draws the division of labor for you.
The resolution is envelope encryption. Kotlin generates a random data encryption key, the DEK, which does the file work in Rust. The Keystore holds a key encryption key, the KEK, with exactly one job: wrapping and unwrapping those 32 DEK bytes with AES-GCM. Bulk megabytes go through Rust; the secret that must survive device compromise stays behind hardware. Here is the complete app/src/main/java/dev/davthecoder/rustseries/KeyWrapper.kt:
package dev.davthecoder.rustseries
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import java.security.KeyStore
import java.security.SecureRandom
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
/** The Keystore KEK wraps a random DEK; only the DEK crosses into Rust. */
object KeyWrapper {
private const val KEY_ALIAS = "cryptobox-kek"
private const val GCM_TAG_BITS = 128
private const val IV_LEN = 12
fun generateDek(): ByteArray =
ByteArray(32).also { SecureRandom().nextBytes(it) }
fun wrapDek(dek: ByteArray): ByteArray {
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, kek())
return cipher.iv + cipher.doFinal(dek)
}
fun unwrapDek(wrapped: ByteArray): ByteArray {
val iv = wrapped.copyOfRange(0, IV_LEN)
val ciphertext = wrapped.copyOfRange(IV_LEN, wrapped.size)
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.DECRYPT_MODE, kek(), GCMParameterSpec(GCM_TAG_BITS, iv))
return cipher.doFinal(ciphertext)
}
private fun kek(): SecretKey {
val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
keyStore.getKey(KEY_ALIAS, null)?.let { return it as SecretKey }
val generator = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore"
)
generator.init(
KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
.build()
)
return generator.generateKey()
}
}
The bridge itself is three externals in app/src/main/java/dev/davthecoder/rustseries/CryptoBox.kt:
package dev.davthecoder.rustseries
object CryptoBox {
init {
System.loadLibrary("cryptobox")
}
external fun nativeHashFile(path: String): String
external fun nativeSeal(key: ByteArray, plaintext: ByteArray): ByteArray
external fun nativeOpen(key: ByteArray, sealed: ByteArray): ByteArray
}
The whole flow is four lines at the call site:
val dek = KeyWrapper.generateDek()
val wrapped = KeyWrapper.wrapDek(dek) // persist next to the sealed file
val sealed = CryptoBox.nativeSeal(dek, plaintextBytes)
dek.fill(0) // wipe Kotlin's copy
Be honest about that last line. fill(0) wipes the array you hold, but the garbage collector may have copied it earlier, so wiping on the managed side is best effort. The real mitigations are a short DEK lifetime, unwrapping only for the duration of an operation, and letting the KEK stay where no memcpy can reach it.
Do not invent protocols
Everything above assembles other people’s constructions, and that is the guidance: crates for primitives, published constructions for everything else. The failure mode for app crypto in 2026 is rarely a broken cipher. It is sound primitives composed into an unsound protocol: AES-CTR with a hash glued on, a key derived by hashing a password once, a chunked format where chunks can be reordered or truncated undetected.
Each of those has a boring, published answer. Authenticated encryption is the AEAD you already have. Password-based keys go through a real password hash like Argon2, which RustCrypto also packages (docs.rs/argon2). Files too large for one AEAD call use STREAM, implemented in the aead crate’s stream module (docs.rs/aead): chunked encryption is a dependency, not a design exercise. If your requirement does not map to hash this, seal this, or wrap this key, involve someone who reviews protocols for a living. I write Rust crypto plumbing; I do not design protocols, and neither should your app team.
Frequently Asked Questions
Should I use ring or RustCrypto in an Android app?
Default to RustCrypto for app work: pure Rust keeps the cargo-ndk build simple across all four ABIs, each primitive is a small separate dependency, and the AEAD crates carry an NCC Group audit with no significant findings. Choose ring when you need its TLS-adjacent primitives or already depend on it, and accept that its BoringSSL-derived C and assembly come along in the build.
Can Rust use Android Keystore keys directly?
No, by design. Hardware-backed Keystore key material never enters the application process, so no JNI call can hand it to Rust. The working pattern is envelope encryption: Kotlin asks the Keystore to wrap and unwrap a random data key, and only that data key crosses the boundary for the bulk work in Rust.
Why not do all of this in Kotlin with the Keystore?
The Keystore is the right tool for small operations like wrapping a 32-byte key, and javax.crypto is fine for modest payloads. Rust earns its place for bulk work over large files and for cores shared across platforms. It also fills a gap Google left: Jetpack’s security-crypto library, the one behind EncryptedFile, is deprecated (AndroidX release notes).
Is it safe to pass key bytes across JNI?
The DEK exists in process memory on both sides either way, so crossing JNI does not change its exposure class. Reduce the window: unwrap the key only when needed, zeroize the Rust copy after each operation, wipe the Kotlin array, and keep the KEK inside the Keystore.
Where this leaves you
The cryptobox module is short because I outsourced every hard decision to something audited. The AEAD checks its own tag, the oversized nonce removes the bookkeeping that breaks GCM deployments, and the KEK never leaves the hardware. What’s left is plumbing, and in cryptography, boring is the compliment. If a future diff makes this module more interesting, treat that as a review flag.
Part 13 covers what these crates weigh. Binary Size and the 16KB Page Requirement: Optimizing Rust .so Files for Release walks the size playbook over the series’ own crates. The full series map lives at the hub.
Sources
- Google, “Rust in Android: move fast and fix things”, retrieved 2026-10-11, https://blog.google/security/rust-in-android-move-fast-fix-things/
- Codenomicon, “The Heartbleed Bug”, retrieved 2026-10-11, https://heartbleed.com/
- NIST, SP 800-38D, “Recommendation for Block Cipher Modes of Operation: Galois/Counter Mode (GCM) and GMAC”, retrieved 2026-10-11, https://csrc.nist.gov/pubs/sp/800/38/d/final
- docs.rs, ring crate, retrieved 2026-10-11, https://docs.rs/ring/latest/ring/
- docs.rs, chacha20poly1305 crate (0.10), retrieved 2026-10-11, https://docs.rs/chacha20poly1305/latest/chacha20poly1305/
- docs.rs, blake3 crate (1.x), retrieved 2026-10-11, https://docs.rs/blake3/latest/blake3/
- Android Developers, “Android Keystore system”, retrieved 2026-10-11, https://developer.android.com/privacy-and-security/keystore
- Android Developers, “Security” (AndroidX releases), retrieved 2026-10-11, https://developer.android.com/jetpack/androidx/releases/security

Loading comments…