Skip to main content

Cryptography & Android Keystore

Application-level cryptography on Android is a combination of JCE primitives (AES, RSA, ECDSA, HMAC) and the Android Keystore system (hardware-backed key storage + attestation). Get both right and you can build apps that survive rooted devices, device theft, and forensic analysis. Get them wrong and you get a ticker-tape of CVEs.

Algorithm cheat sheet

NeedAlgorithmWhy
Symmetric encryptionAES-256-GCMFast, authenticated, standard
Asymmetric encryption / signingRSA-4096 / ECDSA (secp256r1)Standard for public-key crypto
Key exchangeECDH (secp256r1)Forward-secret session keys
Message authenticationHMAC-SHA256Check integrity of a payload
Password hashingArgon2id / scrypt / bcryptSlow on purpose; resist brute force
Key derivation from a passwordPBKDF2-HMAC-SHA256 (100k+ rounds)Turn password into a key
Random tokens / noncesSecureRandomCryptographically secure RNG
Hashing (dedup, checksum)SHA-256Fast, collision-resistant

Never use: MD5, SHA-1, DES, 3DES, ECB mode, PKCS#1 v1.5 padding (RSA), weak entropy.


AES-GCM with Keystore

class KeystoreCrypto {
companion object {
private const val KEYSTORE = "AndroidKeyStore"
private const val TRANSFORMATION = "AES/GCM/NoPadding"
private const val GCM_TAG_LENGTH = 128
}

private val keyStore = KeyStore.getInstance(KEYSTORE).apply { load(null) }

fun getOrCreateKey(alias: String, requireAuth: Boolean = false): SecretKey {
(keyStore.getKey(alias, null) as? SecretKey)?.let { return it }

val spec = KeyGenParameterSpec.Builder(
alias,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
.setRandomizedEncryptionRequired(true)
.apply {
if (requireAuth) {
setUserAuthenticationRequired(true)
setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG)
setInvalidatedByBiometricEnrollment(true)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
setIsStrongBoxBacked(hasStrongBox())
}
}
.build()

val gen = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE)
gen.init(spec)
return gen.generateKey()
}

fun encrypt(alias: String, plaintext: ByteArray, aad: ByteArray? = null): EncryptedPayload {
val cipher = Cipher.getInstance(TRANSFORMATION).apply {
init(Cipher.ENCRYPT_MODE, getOrCreateKey(alias))
aad?.let { updateAAD(it) }
}
val ciphertext = cipher.doFinal(plaintext)
return EncryptedPayload(iv = cipher.iv, ciphertext = ciphertext)
}

fun decrypt(alias: String, payload: EncryptedPayload, aad: ByteArray? = null): ByteArray {
val cipher = Cipher.getInstance(TRANSFORMATION).apply {
val spec = GCMParameterSpec(GCM_TAG_LENGTH, payload.iv)
init(Cipher.DECRYPT_MODE, getOrCreateKey(alias), spec)
aad?.let { updateAAD(it) }
}
return cipher.doFinal(payload.ciphertext)
}

private fun hasStrongBox(): Boolean =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
context.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE)
} else false
}

data class EncryptedPayload(val iv: ByteArray, val ciphertext: ByteArray)

AAD — associated authenticated data

GCM's updateAAD(bytes) lets you bind context to the ciphertext:

val payload = crypto.encrypt(
alias = "user_data",
plaintext = userPii,
aad = "user-id-${userId}".toByteArray()
)

// On decryption, you MUST pass the same AAD or decryption fails
crypto.decrypt(alias = "user_data", payload = payload, aad = "user-id-$userId".toByteArray())

If an attacker copies an encrypted blob from user A's account and tries to feed it to user B's decryption flow, the AAD mismatch makes it fail.


StrongBox — hardware-hardened keys

Keys declared with setIsStrongBoxBacked(true) live in a separate, tamper-resistant chip (Pixel 3+, Samsung Knox, other OEMs):

val spec = KeyGenParameterSpec.Builder(alias, ...)
.setIsStrongBoxBacked(true)
.build()

If StrongBox isn't available, key generation throws StrongBoxUnavailableException — fall back to the TEE (still hardware-backed, just less isolated):

val spec = try {
builder.setIsStrongBoxBacked(true).build()
} catch (e: StrongBoxUnavailableException) {
builder.setIsStrongBoxBacked(false).build()
}

For financial / health / highly-sensitive data, prefer StrongBox with fallback.


Key attestation — prove the key is hardware-backed

Key attestation generates a certificate chain proving:

  • The key exists on this device
  • The key is in hardware (TEE or StrongBox)
  • The device is unmodified (bootloader locked, verified boot passed)
fun generateAttestedKey(alias: String, challenge: ByteArray): X509Certificate {
val spec = KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_SIGN)
.setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1"))
.setDigests(KeyProperties.DIGEST_SHA256)
.setAttestationChallenge(challenge) // server-provided nonce
.setIsStrongBoxBacked(true)
.build()

val keyPairGen = KeyPairGenerator.getInstance("EC", "AndroidKeyStore")
keyPairGen.initialize(spec)
keyPairGen.generateKeyPair()

val chain = (KeyStore.getInstance("AndroidKeyStore").apply { load(null) })
.getCertificateChain(alias)

return chain[0] as X509Certificate // has the attestation extension
}

Your backend verifies the certificate chain against Google's root and extracts the KeyDescription extension — proving the key's properties (hardware-backed, biometric-required, etc.).

Use for:

  • Device attestation for payment authorization
  • Proving a user's key is still on the original device (detect cloning)
  • MDM enrollment verification

ECDSA signing with biometric binding

For payment authorization:

fun createPaymentKey(): KeyPair {
val spec = KeyGenParameterSpec.Builder("payment_key", KeyProperties.PURPOSE_SIGN)
.setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1"))
.setDigests(KeyProperties.DIGEST_SHA256)
.setUserAuthenticationRequired(true)
.setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG)
.setInvalidatedByBiometricEnrollment(true)
.build()

val gen = KeyPairGenerator.getInstance("EC", "AndroidKeyStore")
gen.initialize(spec)
return gen.generateKeyPair()
}

suspend fun signPaymentAuthorization(
activity: FragmentActivity,
payload: ByteArray
): ByteArray = suspendCancellableCoroutine { cont ->
val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
val privateKey = keyStore.getKey("payment_key", null) as PrivateKey
val signature = Signature.getInstance("SHA256withECDSA").apply { initSign(privateKey) }

val prompt = BiometricPrompt(
activity,
ContextCompat.getMainExecutor(activity),
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
val authedSig = result.cryptoObject!!.signature!!
authedSig.update(payload)
cont.resume(authedSig.sign())
}
override fun onAuthenticationError(code: Int, message: CharSequence) {
cont.resumeWithException(BiometricException(code, message.toString()))
}
}
)
val info = BiometricPrompt.PromptInfo.Builder()
.setTitle("Authorize payment")
.setSubtitle("Confirm ₹3,499 charge")
.setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)
.setNegativeButtonText("Cancel")
.build()

prompt.authenticate(info, BiometricPrompt.CryptoObject(signature))
}

Server verifies the signature with the public key registered at enrollment. Each authorization requires a fresh biometric prompt.


Envelope encryption

For large payloads, encrypt once with a data encryption key (DEK), encrypt the DEK with the key encryption key (KEK) in Keystore:

// Generate a random DEK per payload
val dek = ByteArray(32).apply { SecureRandom().nextBytes(this) }
val dekKey = SecretKeySpec(dek, "AES")

// Encrypt payload with DEK
val payloadCipher = Cipher.getInstance("AES/GCM/NoPadding").apply { init(Cipher.ENCRYPT_MODE, dekKey) }
val payloadCiphertext = payloadCipher.doFinal(largePlaintext)
val payloadIv = payloadCipher.iv

// Encrypt DEK with KEK (in Keystore)
val wrappedDek = keystoreCrypto.encrypt(alias = "kek", plaintext = dek)

// Save both; the DEK is now safely encrypted-at-rest

On decryption, unwrap the DEK with the KEK, then use the DEK to decrypt the payload. Advantages:

  • Encrypt large files at high throughput (DEK is in memory)
  • Rotate the KEK without re-encrypting every file
  • DEK can be destroyed without losing the ciphertext history

Tink — high-level primitives

Tink is Google's crypto library with safer defaults — it's harder to misuse. If you don't need Keystore integration, use Tink for everything:

// libs.versions.toml
tink = { module = "com.google.crypto.tink:tink-android", version = "1.15.0" }
// One-time init
AeadConfig.register()

// Generate or load a keyset
val keysetHandle = AndroidKeysetManager.Builder()
.withSharedPref(context, "master_keyset", "crypto_prefs")
.withKeyTemplate(KeyTemplates.get("AES256_GCM"))
.withMasterKeyUri("android-keystore://master_key")
.build()
.keysetHandle

val aead = keysetHandle.getPrimitive(Aead::class.java)

// Encrypt
val ciphertext = aead.encrypt(plaintext, associatedData)

// Decrypt
val decrypted = aead.decrypt(ciphertext, associatedData)

The KeysetManager stores the encrypted keyset in SharedPreferences, with the master key in Keystore. Handles IV generation, key rotation, and format versioning. This is the pattern EncryptedSharedPreferences uses internally.


Password hashing (for backend-stored passwords)

Android apps rarely hash passwords locally — that's the server's job. But if you must (offline-only auth, sync tokens, decryption keys derived from PIN):

Argon2id (best for new code)

// libs.versions.toml
argon2-kt = { module = "com.lambdapioneer.argon2kt:argon2kt", version = "1.5.0" }
val argon2 = Argon2Kt()
val result = argon2.hash(
Argon2Mode.ARGON2_ID,
password = password.toByteArray(),
salt = ByteArray(16).apply { SecureRandom().nextBytes(this) },
tCostInIterations = 3,
mCostInKibibyte = 65536,
parallelism = 4,
hashLengthInBytes = 32
)

val encoded = result.encodedOutputAsString() // "$argon2id$v=19$m=65536,t=3,p=4$...$..."

PBKDF2 (available without libraries)

fun pbkdf2(password: CharArray, salt: ByteArray, iterations: Int = 100_000, keyLength: Int = 256): ByteArray {
val spec = PBEKeySpec(password, salt, iterations, keyLength)
val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")
return factory.generateSecret(spec).encoded
}

Secure random

// ✅ Cryptographically secure
val bytes = ByteArray(32).apply { SecureRandom().nextBytes(this) }

// ❌ Predictable — NEVER use for tokens, IVs, nonces
val bytes = ByteArray(32).apply { Random.nextBytes(this) }

Android's SecureRandom uses /dev/urandom. Don't seed it — the defaults are correct.


Clearing sensitive data

// ✅ CharArray can be zeroed
val password = CharArray(pwLength)
/* use */
password.fill(' ')

// ❌ String is immutable — you can't wipe it
val password = "secret"

For any in-memory secret (password, decrypted token, PIN), use CharArray / ByteArray and .fill('�') in a finally.


Network Security Config (recap)

See Module 16 overview for the full cert-pinning story. Key points:

<!-- res/xml/network_security_config.xml -->
<network-security-config>
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system"/>
</trust-anchors>
</base-config>

<domain-config>
<domain includeSubdomains="true">api.example.com</domain>
<pin-set expiration="2026-12-31">
<pin digest="SHA-256">AAAA...</pin>
<pin digest="SHA-256">BBBB...</pin> <!-- backup -->
</pin-set>
</domain-config>

<debug-overrides>
<trust-anchors>
<certificates src="user"/> <!-- Charles / Proxyman in debug -->
</trust-anchors>
</debug-overrides>
</network-security-config>

Reverse-engineering defense (layered)

Full coverage in Module 16 overview. Crypto-specific:

  • Never embed keys in APK — they're trivial to extract from strings.xml or bytecode.
  • Key obfuscation is not security; it's "buy me 2 extra minutes."
  • Runtime key derivation from device + user + server challenge is the right pattern. Attacker must compromise multiple layers to extract.
  • R8 full-mode + keep rules carefully — aggressive shrinking makes reverse engineering harder but doesn't protect keys.

Common anti-patterns

Anti-patterns

Crypto mistakes

  • AES in ECB mode (pattern leaks)
  • Hardcoded keys in BuildConfig
  • PBKDF2 with 1000 iterations (brute-forceable)
  • MD5 / SHA-1 for anything security-related
  • Reusing IVs with AES-GCM (catastrophic)
  • RSA PKCS#1 v1.5 padding (prefer OAEP)
  • Random.nextBytes() for nonces
Best practices

Modern crypto

  • AES-GCM (authenticated + per-op IV)
  • Keystore-generated keys or derived at runtime
  • PBKDF2 with 100k+ iterations, or Argon2id
  • SHA-256 or SHA-3 for hashing
  • Fresh IV per encryption (SecureRandom)
  • RSA-OAEP (SHA-256) if you must use RSA
  • SecureRandom always for crypto randomness

Testing cryptography

@Test fun encrypt_decrypt_round_trip() {
val plaintext = "secret message".toByteArray()
val encrypted = crypto.encrypt("test_key", plaintext)
val decrypted = crypto.decrypt("test_key", encrypted)
assertArrayEquals(plaintext, decrypted)
}

@Test fun tampering_detected() {
val plaintext = "secret message".toByteArray()
val encrypted = crypto.encrypt("test_key", plaintext)

// Tamper with ciphertext
encrypted.ciphertext[0] = encrypted.ciphertext[0].inc()

assertThrows<AEADBadTagException> {
crypto.decrypt("test_key", encrypted)
}
}

@Test fun different_aad_fails_decrypt() {
val encrypted = crypto.encrypt("test_key", plaintext, aad = "context-a".toByteArray())
assertThrows<AEADBadTagException> {
crypto.decrypt("test_key", encrypted, aad = "context-b".toByteArray())
}
}

Round-trip, tampering, and context-mismatch tests catch the common bugs.


Key takeaways

Practice exercises

  1. 01

    Build KeystoreCrypto

    Implement the KeystoreCrypto class above. Test round-trip, tampering detection, and AAD mismatch.

  2. 02

    Envelope encryption

    Encrypt a 10 MB file with a random DEK; encrypt the DEK with a Keystore KEK. Verify decryption works end-to-end.

  3. 03

    Biometric payment signing

    Create an ECDSA signing key with setUserAuthenticationRequired. Sign a payload after a BiometricPrompt.

  4. 04

    Key attestation

    Generate an attested key pair. Inspect the attestation certificate extension (Android ID in the X.509 extension).

  5. 05

    Integrate Tink

    Replace your EncryptedSharedPreferences with a Tink-backed encrypted keyset. Measure performance vs AndroidX implementation.

Next

Continue to Play Integrity API for runtime app / device integrity verification.