Cryptography & Biometrics

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.

Note: The Jetpack Security library is an abstraction over these APIs and helps with encrypting SharedPreferences or files, but currently does not support biometrics.

Links:

Workflow

Encryption:

  1. Obtain a SecretKey:
    • Either create a new key in the Android KeyStore
    • Or get a key that you created previously from Android KeyStore
  2. Create a Cipher and initialize it with the SecretKey
  3. Encrypt data with the Cipher
  4. 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:

  1. Get a SecretKey that you created previously from Android KeyStore
  2. Create a Cipher and initialize it with the SecretKey
  3. Optional: Depend on the used transformation, you will also need to provide the previously stored initialization vector to the Cipher.
  4. 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
  • AES is an industry standard and is available on all Android versions.
  • DES is the predecessor of AES.
  • RSA is used for asymmetric (public / private key) cryptography.
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 ByteArrays, as most algorithms work on blocks of bytes (e.g. 128 bit blocks for AES). You can use Base64 for easy conversion of Strings:

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:

  1. Check that authentication is possible (user has fingerprints enrolled) with biometricManager.canAuthenticate(BIOMETRIC_STRONG)
  2. Create a new SecretKey with setUserAuthenticationRequired(true). Trying to encrypt / decrypt with this key without authentication will cause an exception.
  3. Create Cipher that is initialized with the SecretKey (and IV for decryption) and pass it to biometricPrompt.authenticate(info, CryptoObject(cipher))
  4. In the onAuthenticationSucceeded callback, get the Cipher from the AuthenticationResult. It can now be used for encryption / decryption.

If you want to require authentication only for decryption (e.g. to allow storing encrypted tokens after token refresh without user intervention) you can create a secret key yourself (call KeyGenerator.getInstance(ALGORITHM).generateKey()) and add the key to the key store under two alias with different KeyProtection:

  • One alias only for encryption, which does not require authentication
  • One alias only for decryption, which requires authentication

Link: How do I require user authentication only for decryption but not encryption

NOTE: Keys can not be exported and will be removed, when the app is uninstalled. They are not part of android:allowBackup.

NOTE: If a user makes changes to the biometric settings (i.e. removes all fingerprints in system settings) the secret key will be invalidated by default. It will not be removed from the key store, but it will throw an exception, when trying to encrypt/decrypt it. In that case the only option is to remove it and create a new key.

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)