Android Keystore and crypto — source audit
TL;DR: Android Keystore stores keys in hardware where supported (TEE / StrongBox). Source-audit risks: keys stored outside Keystore (raw bytes in SharedPreferences), AES-GCM with IV reuse, ECB mode anywhere, hash-based password checks with MD5/SHA1, key purposes too broad, and missing biometric ACLs on sensitive operations. Companion to android-source-review-methodology and mobile-client-storage-source-audit.
Where to start
1
2
3
4
5
6
grep -rnE 'KeyStore\.getInstance|KeyGenParameterSpec|KeyPairGenerator\.getInstance' src/
grep -rnE 'Cipher\.getInstance' src/
grep -rnE 'SecretKeySpec|IvParameterSpec|GCMParameterSpec' src/
grep -rnE 'MessageDigest\.getInstance' src/
grep -rnE 'BiometricPrompt|FingerprintManager' src/
grep -rnE '"AndroidKeyStore"' src/
Pattern 1 — key material not in Keystore
Bad — raw bytes derived from PBKDF2, then stored:
1
2
val sp = ctx.getSharedPreferences("vault", MODE_PRIVATE)
sp.edit().putString("aesKey", Base64.encodeToString(rawKey, NO_WRAP)).apply()
Or worse — hardcoded:
1
private val KEY = "ThisIsMySecretKey".toByteArray()
Good — generated inside Keystore, never exits hardware:
1
2
3
4
5
6
7
8
9
10
val spec = KeyGenParameterSpec.Builder("vault.aes",
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setUserAuthenticationRequired(true)
.setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG)
.setIsStrongBoxBacked(true) // require StrongBox where available
.build()
val kg = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
kg.init(spec); kg.generateKey()
Audit grep: any SecretKeySpec(rawBytes, "AES") is a red flag — the key didn’t come from Keystore.
Pattern 2 — AES-GCM IV reuse
GCM’s 96-bit IV must never repeat for the same key. Two patterns that get you:
1
2
val iv = ByteArray(12) // BAD: all-zero IV
val iv = "0123456789ab".toByteArray() // BAD: constant
For Keystore-managed AES-GCM the recommended pattern is to let Cipher generate the IV and read it back:
1
2
3
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, key)
val iv = cipher.iv // unique per init
For AES-CBC, a SecureRandom-derived IV is mandatory; static IV = catastrophic.
Pattern 3 — ECB
Anything AES/ECB/... is wrong outside of single-block primitives. Easy grep:
1
grep -rn 'AES/ECB\|DES/ECB' src/
If you find ECB used to encrypt anything longer than 16 bytes, escalate.
Pattern 4 — broken hashes
1
grep -rn 'MessageDigest\.getInstance("MD5"\|MessageDigest\.getInstance("SHA-1"\|MessageDigest\.getInstance("SHA1"' src/
Both should never appear in password handling, signature checks, or HMAC keys. SHA-1 in TLS pinning or git-style content addressing is acceptable for non-security uses; flag for context.
For password hashing, expect BCrypt, Argon2, or PBKDF2WithHmacSHA256 with ≥100k iterations.
Pattern 5 — key purposes too broad
KeyGenParameterSpec.Builder takes a purpose bitmask. Common bad: PURPOSE_ENCRYPT or PURPOSE_DECRYPT or PURPOSE_SIGN or PURPOSE_VERIFY — overlap surface. Audit:
1
grep -rn 'PURPOSE_' src/
Each key should have one purpose. Sign keys signed should never decrypt.
Pattern 6 — setUserAuthenticationRequired(false) on sensitive keys
If a key encrypts the user’s vault, it should require biometric/credential auth at decrypt time.
1
2
.setUserAuthenticationRequired(true)
.setUserAuthenticationParameters(0, AUTH_BIOMETRIC_STRONG)
Setting it false (or setting a long auth-validity duration) means a malicious process with the user’s lock state recently active gets free decrypts.
Audit:
1
grep -rn 'setUserAuthenticationRequired\|setUserAuthenticationValidityDuration' src/
Pattern 7 — biometric prompt without crypto binding
Bad:
1
2
BiometricPrompt(activity, executor, callback).authenticate(promptInfo)
if (success) decryptWithoutBinding(blob)
Good — bind the cipher to the prompt:
1
2
3
val cipher = ... ; cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(128, iv))
prompt.authenticate(promptInfo, CryptoObject(cipher))
// onAuthenticationSucceeded → result.cryptoObject.cipher.doFinal(blob)
The bound variant requires successful biometric per use of the cipher; the unbound variant just shows a dialog.
Pattern 8 — KeyInfo.isInsideSecureHardware() not checked
Apps that require hardware-backed keys should check:
1
2
3
val factory = SecretKeyFactory.getInstance(key.algorithm, "AndroidKeyStore")
val info = factory.getKeySpec(key, KeyInfo::class.java) as KeyInfo
require(info.isInsideSecureHardware) { "software fallback unacceptable" }
Without this check, a device without TEE silently downgrades to software keystore — same API, much weaker storage.
Pattern 9 — key attestation
For high-assurance flows (banking, government IDs) you can require remote-attestable keys: setAttestationChallenge(challenge) on the spec, then send the attestation certificate chain to your server for verification against Google’s root.
1
grep -rn 'setAttestationChallenge\|getCertificateChain' src/
Absence isn’t bad; presence with no server-side verification is.
EncryptedSharedPreferences and Jetpack Security
Jetpack Security (androidx.security:security-crypto) provides EncryptedSharedPreferences and EncryptedFile. Keys are managed inside Keystore via a master key.
1
grep -rn 'EncryptedSharedPreferences\|EncryptedFile\|MasterKeys\|MasterKey\.' src/
Audit:
MasterKey.Builder(...).setUserAuthenticationRequired(true)for sensitive vaults.- Library version current (older versions had vulnerabilities — track CVEs).
Source-audit checklist
- All long-lived keys are
AndroidKeyStore-backed. - No raw
SecretKeySpec(bytes, "AES")for app secrets. - No
AES/ECB/.... - No constant IVs.
- No MD5 / SHA-1 used for password or signature.
setUserAuthenticationRequired(true)on keys gating sensitive data.- BiometricPrompt uses CryptoObject binding for decrypt.
isInsideSecureHardwareenforced where the threat model requires it.