Implementing cryptographic operations in Android without a library can be quite confusing, because the API is based on the ancient Java Cryptography Architecture, which was introduced with JDK 1.1. However from a high-level point of view, encrypting and decrypting data in Android is relatively straight forward.
Links:
Workflow
Encryption:
- Obtain a
SecretKey
:- Either create a new key in the Android
KeyStore
- Or get a key that you created previously from Android
KeyStore
- Either create a new key in the Android
- Create a
Cipher
and initialize it with theSecretKey
- Encrypt data with the
Cipher
- Optional: Depending on the used transformation (block mode), you also need to store the
Cipher
’s generated initialization vector (cipher.iv
), e.g. by concatenating it with the encrypted data.
Decryption:
- Get a
SecretKey
that you created previously from AndroidKeyStore
- Create a
Cipher
and initialize it with theSecretKey
- Optional: Depend on the used transformation, you will also need to provide the previously stored initialization vector to the
Cipher
. - Decrypt data with the
Cipher
Choosing a transformation
A “transformation” describes the cryptographic operations that should be used. Its format looks like this: "$ALGORITHM/$BLOCK_MODE/$PADDING"
. Google recommends AES/GCM/NoPadding with 256-bit keys.
Algorithm
Block mode
The algorithms encrypt data in blocks of fixed length, e.g. AES only encrypts 128 bit blocks. If the data to be encrypted is longer, multiple blocks have to be chained. This is handled by the block mode (ECB, CBC, CTR, GCM).
Never use ECB, as as it forms patterns.
In most modes, the first block has to be initialized with an initialization vector (IV). This IV is generated by the Cipher
during encryption, so it can not accidentally be reused when encrypting other data. Block modes affect performance (i.e. encrypting large files).
GCM also has an “authentication tag”, which is used to check data integrity. It is automatically appended to the encrypted data during encryption and split off during decryption. The tag has a length of 128 bits. It is possible (but not recommended) to store only 120, 112, 104 or 96 bits of the tag, e.g. if the blocks size is smaller than 128 bits.
Padding
If the last block does not fit the length of the data to be encrypted, it has to be padded (NoPadding, PKCS5Padding, PKCS7Padding).
Key size
AES has multiple key sizes: 128, 192, 256. All key sizes are secure and differences are mostly theoretical or regulatory.
Generating secret keys
There are two ways to generate keys:
- Generate one yourself and put it in the
KeyStore
- Let the key store generate one via
KeyGenParameterSpec
Letting the Android key store handle key generation is most secure, as the actual key never leaves the trusted enviroment. Example (AES/GCM/NoPadding
):
private fun generateKey(keyAlias: String) {
val spec = KeyGenParameterSpec.Builder(keyAlias, PURPOSE_ENCRYPT or PURPOSE_DECRYPT)
.setKeySize(256)
.setBlockModes(BLOCK_MODE_GCM)
.setEncryptionPaddings(ENCRYPTION_PADDING_NONE)
.build()
// use "AndroidKeyStore" as a security provider, so key is directly saved in key store
val keyGenerator = KeyGenerator.getInstance(KEY_ALGORITHM_AES, "AndroidKeyStore")
keyGenerator.init(spec)
keyGenerator.generateKey()
}
private fun getKey(keyAlias: String): Key {
private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply {
load(null) // before the keystore can be accessed it must be loaded
}
return keyStore.getKey(keyAlias, null)
}
Using a Cipher
fun encrypt(plainBytes: ByteArray, secretKey: Key): Pair<ByteArray, ByteArray> {
val cipher = Cipher.getInstance("$ALGORITHM/$BLOCK_MODE/$PADDING")
cipher.init(ENCRYPT_MODE, secretKey)
val encryptedBytes: ByteArray = cipher.doFinal(plainBytes)
return Pair(cipher.iv, encryptedBytes)
}
fun decrypt(encryptedBytes: ByteArray, iv: ByteArray, secretKey: Key): ByteArray {
val cipher = Cipher.getInstance("$ALGORITHM/$BLOCK_MODE/$PADDING")
val spec: AlgorithmParameterSpec = when (BLOCK_MODE) {
BLOCK_MODE_GCM -> GCMParameterSpec(GCM_TAG_SIZE, iv)
else -> IvParameterSpec(iv)
}
cipher.init(DECRYPT_MODE, secretKey, spec)
return cipher.doFinal(bytes)
}
Encoding Strings
Input for encryption and decryption must be ByteArray
s, as most algorithms work on blocks of bytes (e.g. 128 bit blocks for AES). You can use Base64 for easy conversion of String
s:
private fun Cipher.encrypt(plainText: String): String {
val plainTextBytes = plainText.encodeToByteArray()
val cipherBytes = doFinal(plainTextBytes)
return cipherBytes.encodeToBase64()
}
private fun Cipher.decrypt(cipherText: String): String {
val cipherBytes = cipherText.decodeFromBase64()
val plainTextBytes = doFinal(cipherBytes)
return plainTextBytes.decodeToString()
}
private fun ByteArray.encodeToBase64(): String = Base64.encodeToString(this, Base64.DEFAULT)
private fun String.decodeFromBase64(): ByteArray = Base64.decode(this, Base64.DEFAULT)
Biometric auth
You can show a biometric authentication dialog with the androidx biometric library. You can use this as a simple dialog with a success and error callback. It supports multiple authentication strengths (i.e. fingerprints, face unlock, swipe pattern, unlock PIN, etc.), depending on Android API level.
However you can also use it to protect a SecretKey
, so user authentication is required before the key can be used for encrypting or decrypting. When using the biometric prompt like this, you can only use BIOMETRIC_STRONG
authentication, so a fallback to non-biometric credentials (PIN) is not allowed and it will not work on older devices.
To use it like this there are multiple steps to complete:
- Check that authentication is possible (user has fingerprints enrolled) with
biometricManager.canAuthenticate(BIOMETRIC_STRONG)
- Create a new
SecretKey
withsetUserAuthenticationRequired(true)
. Trying to encrypt / decrypt with this key without authentication will cause an exception. - Create
Cipher
that is initialized with theSecretKey
(and IV for decryption) and pass it tobiometricPrompt.authenticate(info, CryptoObject(cipher))
- In the
onAuthenticationSucceeded
callback, get the Cipher from theAuthenticationResult
. It can now be used for encryption / decryption.
Full example
/**
* We're using AES/GCM/NoPadding with 256-bit keys as
* [recommended](https://developer.android.com/guide/topics/security/cryptography#choose-algorithm)
* by Google.
*/
private object CryptoSpec {
const val ALGORITHM = KEY_ALGORITHM_AES
const val KEY_SIZE = 256 // AES-256
const val BLOCK_MODE = BLOCK_MODE_GCM
const val PADDING = ENCRYPTION_PADDING_NONE
const val GCM_TAG_SIZE = 128 // auth tag is automatically appended to the ciphertext, so we don't need to handle it
const val IV_SEPARATOR = ":" // not part of Base64, so does not require escaping
const val KEY_STORE = "AndroidKeyStore"
}
/**
* Allows encrypting an decrypting arbitrary Strings.
* The encrypted format contains the ciphertext (encrypted text) combined with the
* generated initialization vector (IV) for later decryption.
*/
class CryptographyService {
private val keyStore
get() = KeyStore.getInstance(KEY_STORE).apply {
load(null) // before the keystore can be accessed it must be loaded
}
fun encrypt(plainText: String, keyAlias: String): String {
try {
val secretKey = keyStore.getOrCreateSecretKey(keyAlias)
val cipher = createCipher(ENCRYPT_MODE, secretKey)
return cipher.encrypt(plainText)
} catch (e: Exception) { // e.g. NoSuchAlgorithmException, etc.
keyStore.reset(keyAlias)
throw e
}
}
fun decrypt(ivAndCipherText: String, keyAlias: String): String {
try {
val secretKey = keyStore.getSecretKey(keyAlias) ?: throw CryptoException.KeyNotFound
val (ivSpec, cipherText) = ivAndCipherText.splitIvAndCipherText()
val cipher = createCipher(DECRYPT_MODE, secretKey, ivSpec)
return cipher.decrypt(cipherText)
} catch (e: Exception) { // e.g. KeyPermanentlyInvalidatedException
keyStore.reset(keyAlias)
throw e
}
}
}
private fun createCipher(opmode: Int, key: Key, params: AlgorithmParameterSpec? = null) = Cipher
.getInstance("$ALGORITHM/$BLOCK_MODE/$PADDING")
.apply { if (params != null) init(opmode, key, params) else init(opmode, key) }
private fun KeyStore.getOrCreateSecretKey(keyAlias: String): Key {
var secretKey = getSecretKey(keyAlias)
if (secretKey == null) {
generateSecretKey(keyAlias)
secretKey = getSecretKey(keyAlias)
}
return secretKey ?: throw CryptoException.KeyNotFound
}
private fun KeyStore.getSecretKey(keyAlias: String) = getKey(keyAlias, null)
private fun KeyStore.reset(keyAlias: String) = runCatching { deleteEntry(keyAlias) }
private fun KeyStore.generateSecretKey(keyAlias: String) {
val spec = KeyGenParameterSpec.Builder(keyAlias, PURPOSE_ENCRYPT or PURPOSE_DECRYPT)
.setKeySize(KEY_SIZE)
.setBlockModes(BLOCK_MODE)
.setEncryptionPaddings(PADDING)
.build()
val keyGenerator = KeyGenerator
.getInstance(ALGORITHM, KEY_STORE)
.apply { init(spec) }
keyGenerator.generateKey()
}
private fun String.splitIvAndCipherText(): Pair<AlgorithmParameterSpec, String> {
val (ivString, cipherText) = split(IV_SEPARATOR)
val ivSpec = GCMParameterSpec(GCM_TAG_SIZE, ivString.decodeFromBase64())
return Pair(ivSpec, cipherText)
}
private fun Cipher.encrypt(plainText: String): String {
val ivString = iv.encodeToBase64()
val plainTextBytes = plainText.encodeToByteArray()
val cipherBytes = doFinal(plainTextBytes)
val cipherText = cipherBytes.encodeToBase64()
return "$ivString$IV_SEPARATOR$cipherText"
}
private fun Cipher.decrypt(cipherText: String): String {
val cipherBytes = cipherText.decodeFromBase64()
val plainTextBytes = doFinal(cipherBytes)
return plainTextBytes.decodeToString()
}
private fun ByteArray.encodeToBase64(): String = Base64.encodeToString(this, Base64.DEFAULT)
private fun String.decodeFromBase64(): ByteArray = Base64.decode(this, Base64.DEFAULT)