Security

Secure Mobile Biometric Authentication: Best Practices and Implementation Guidelines for Kotlin, Swift, and Flutter

In this Article, we define a secure implementation of mobile biometric authentication and provide detailed implementations in the 3 main modern mobile languages, namely Kotlin for Android, Swift for iOS, and Dart for Flutter multiplatform applications.

Tue 20 June 2023

Introduction

To enhance user experience and security, biometric authentication has gained significant popularity. However, with great convenience comes an even greater responsibility to ensure the security of the implementation.

This article will explore the importance of the secure implementation of mobile biometric authentication and provide detailed implementations in the 3 main modern mobile languages, namely Kotlin for Android, Swift for iOS, and Dart for Flutter multiplatform applications. It is an attempt to address a security gap that we have seen present in most mobile applications we have reviewed.

So What is a Secure Mobile Biometric Authentication?

First and foremost, a secure implementation of mobile biometric authentication guarantees the need to use Face ID or Touch ID authentication to access the application’s sensitive data.

In mobile applications, a secure implementation of biometric authentication goes beyond just verifying the fingerprint or the face to log in. It also includes encrypting the application's sensitive data using the biometric data.

This encryption adds an extra layer of protection, making it highly challenging for unauthorized individuals to access or use sensitive information. Encryption with biometric data becomes crucial in case an unauthorized party gains access to the device, via Malware or physical access.

Without encryption, an attacker can manipulate the memory to bypass the biometric check and log in successfully to the application. However, they would be unable to interpret or utilize the application data if the encryption is used with the biometric data. This helps to maintain the confidentiality of the application's sensitive information, thereby safeguarding the privacy and security of users' data.

Secure Mobile Biometric Authentication

Application Scenario:

In this article we will implement an example of a secure biometric authentication for Kotlin, Swift, and Flutter (Dart). The full workflow should follow the steps to enable secure biometric authentication:

1- User opens the mobile application and is presented with a login screen.

2- User enters his username and password.

3- The application verifies the provided credentials by sending them to the backend server for authentication.

4- The backend server generates a unique token for the user session if the credentials are valid.

5- The application prompts users to set up biometric authentication (e.g., fingerprint or face recognition) for future logins.

6- The application stores the backend token once the user sets up biometric authentication.

7- The user is successfully logged in and gains access to the application's features and functionalities.

8- In subsequent app launches, the application checks if the user has biometric authentication enabled.

9- If biometric authentication is enabled, the application uses the biometric data to authenticate the user.

10- Upon successful biometric authentication, the application retrieves the backend token from secure storage.

We will suppose that the device has biometric-enabled on the device (we will not include it in the code the checks for simplicity matter)

We will also suppose that the steps from 1 to 5 are done and the user is setting up the biometric for the application, which will require to encrypt the token with the biometric data and read it later on after successful logins.

Secure Mobile Biometric Authentication in Kotlin (Android):

In Android, to store the Backend token after encrypting it with biometric data, we will implement the following:

1- Our app asks the Android KeyStore for a SecretKey.

2- The Android Keystore creates the secret key in the secure location (TEE).

3- The Keystore returns an alias to our app to access the secretKey.

4- We create a Cipher object to perform encryption/decryption (The encryption is done in the Keystore system.)

5- The Keystore system takes in the plaintext and the alias and returns encrypted data called ciphertext.

6- When the app wants to perform decryption, the Keystore system takes in the ciphertext and the alias and returns decrypted data or plaintext.

7- We enable biometric authentication to ask the system to secure the secret key using authentication binding.

8- We use a CryptoObject as a wrapper to carry the cipher.

9- We encrypt the backend Token using the cipher wrapped in the CryptoObject. The CryptoObject is passed as an argument to onAuthenticationSucceeded.

10- We read the Token after a successful login by decrypting using the cipher wrapped in the CryptoObject.

With this implementation, Even if a device should become compromised and an attacker makes a request, the data remains encrypted — unless the attacker can somehow get the user to authenticate with their biometric credentials. Biometric authentication adds an additional layer of security — even on a compromised device — because the hardware-managed Keystore cannot be accessed unless the user is present.

Check the code associated with this flow:

1- Create a function to create and get the SecretKey from Android KeyStore:

// DECLARE CONSTS
val ANDROID_KEYSTORE = "AndroidKeyStore"
private val ENCRYPTION_BLOCK_MODE = KeyProperties.BLOCK_MODE_GCM
private val ENCRYPTION_PADDING = KeyProperties.ENCRYPTION_PADDING_NONE
private val KEY_SIZE: Int = 256

private fun getOrCreateSecretKey(keyName: String): SecretKey {
        // return Secretkey if it was previously created for that keyName.
        val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE)
        keyStore.load(null) // Keystore must be loaded before it can be accessed
        keyStore.getKey(keyName, null)?.let { return it as SecretKey }

        // Create new SecretKey for the provided keyName
        val paramsBuilder = KeyGenParameterSpec.Builder(keyName,
            KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
        paramsBuilder.apply {
            setBlockModes(ENCRYPTION_BLOCK_MODE)
            setEncryptionPaddings(ENCRYPTION_PADDING)
            setKeySize(KEY_SIZE)
        }

        val keyGenParams = paramsBuilder.build()
        val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES,
            ANDROID_KEYSTORE)
        keyGenerator.init(keyGenParams)
        return keyGenerator.generateKey()
    }

2- We create the Cipher object, and we wrap it in the CryptoObject when calling the authenticate method:

// DECLARE CONSTS
private val ENCRYPTION_ALGORITHM = KeyProperties.KEY_ALGORITHM_AES
private lateinit var promptInfo: BiometricPrompt.PromptInfo

private fun authenticateToEncrypt() {       
    if (BiometricManager.from(applicationContext).canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS) {
            val transformation = "$ENCRYPTION_ALGORITHM/$ENCRYPTION_BLOCK_MODE/$ENCRYPTION_PADDING"
            val cipher = Cipher.getInstance(transformation)
            val secretKey = getOrCreateSecretKey(KEY_NAME)
            cipher.init(Cipher.ENCRYPT_MODE, secretKey)
            val biometricPrompt = createEncryptBiometricPrompt()
            biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
        }
    }


private fun authenticateToDecrypt() {
        if (BiometricManager.from(applicationContext).canAuthenticate() == BiometricManager.BIOMETRIC_SUCCESS) {
            val transformation = "$ENCRYPTION_ALGORITHM/$ENCRYPTION_BLOCK_MODE/$ENCRYPTION_PADDING"
            val cipher = Cipher.getInstance(transformation)
            val secretKey = getOrCreateSecretKey(KEY_NAME)
            cipher.init(Cipher.DECRYPT_MODE, secretKey, GCMParameterSpec(128, initializationVector))
            biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
        }
    }

3- We can implement two different BiometricPrompt, one to encrypt the data and one to decrypt the data:

private fun createEncryptBiometricPrompt(): BiometricPrompt {
        val authenticationCallback = object : BiometricPrompt.AuthenticationCallback() {
            override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
                super.onAuthenticationError(errorCode, errString)
                // Handle authentication errors
            }

            override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
                super.onAuthenticationSucceeded(result)
                encryptData(result.cryptoObject)
            }

            override fun onAuthenticationFailed() {
                super.onAuthenticationFailed()
                // Handle authentication failure
            }
        }

        return BiometricPrompt(this, executor, authenticationCallback)
    }

private fun createDecryptBiometricPrompt(): BiometricPrompt {
        val authenticationCallback = object : BiometricPrompt.AuthenticationCallback() {
            override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
                super.onAuthenticationError(errorCode, errString)
                // Handle authentication errors
            }

            override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
                super.onAuthenticationSucceeded(result)
                decryptData(result.cryptoObject)
            }

            override fun onAuthenticationFailed() {
                super.onAuthenticationFailed()
                // Handle authentication failure
            }
        }

        return BiometricPrompt(this, executor, authenticationCallback)

4- The last part is to define the encryptData and decryptData functions. In This example, the sensitive data is the backend token authentication:

private fun encryptData(cipher: Cipher): EncryptedData {
        val ciphertext = cipher.doFinal(backendToken.toByteArray(Charset.forName("UTF-8")))
        return EncryptedData(ciphertext,cipher.iv)
    }

    fun decryptData(ciphertext: ByteArray, cipher: Cipher): String {
        val plaintext = cipher.doFinal(ciphertext)
        return String(plaintext, Charset.forName("UTF-8"))
    }

alt text
Android biometric authentication

What happens if I add a new fingerprint or delete an existing one after enabling biometric authentication in my application?

Similar to the authentication by password, where we need to invalidate the current token session after changing the password, we need to invalidate the access to the SecretKey stored in the Android Keystore after changing the biometric data.

In Android, This is not the default behavior, which means adding a new fingerprint will allow the user to log in and access the sensitive data from the application.

To invalidate the data after the change, we need to set the setUserAuthenticationRequired to true when generating the secretKey for the Android Keystore.

// DECLARE CONSTS
val ANDROID_KEYSTORE = "AndroidKeyStore"
private val ENCRYPTION_BLOCK_MODE = KeyProperties.BLOCK_MODE_GCM
private val ENCRYPTION_PADDING = KeyProperties.ENCRYPTION_PADDING_NONE
private val KEY_SIZE: Int = 256

private fun getOrCreateSecretKey(keyName: String): SecretKey {
        // return Secretkey if it was previously created for that keyName.
        val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE)
        keyStore.load(null) // Keystore must be loaded before it can be accessed
        keyStore.getKey(keyName, null)?.let { return it as SecretKey }

        // Create new SecretKey for the provided keyName
        val paramsBuilder = KeyGenParameterSpec.Builder(keyName,
            KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
        paramsBuilder.apply {
            setBlockModes(ENCRYPTION_BLOCK_MODE)
            setEncryptionPaddings(ENCRYPTION_PADDING)
            setKeySize(KEY_SIZE)
            setUserAuthenticationRequired(true) // WE ADD OUR CALL HERE
        }

        val keyGenParams = paramsBuilder.build()
        val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES,
            ANDROID_KEYSTORE)
        keyGenerator.init(keyGenParams)
        return keyGenerator.generateKey()
    }

Based on the documentation, Setting the setUserAuthenticationRequired to True will invalidate existing data:

sets whether this key is authorized to be used only if the user has been authenticated.

By default, the key is authorized to be used regardless of whether the user has been authenticated.

When user authentication is required:

- The key can only be generated if secure lock screen is set up (see KeyguardManager.isDeviceSecure()). 

Additionally, if the key requires that user authentication takes place for every use of the key (see setUserAuthenticationValidityDurationSeconds(int)), at least one biometric must be enrolled (see BiometricManager.canAuthenticate()).

- The use of the key must be authorized by the user by authenticating to this Android device using a subset of their secure lock screen credentials such as password/PIN/pattern or biometric.

- The key will become irreversibly invalidated once the secure lock screen is disabled (reconfigured to None, Swipe or other mode which does not authenticate the user) or when the secure lock screen is forcibly reset (e.g., by a Device Administrator). 

Additionally, if the key requires that user authentication takes place for every use of the key, it is also irreversibly invalidated once a new biometric is enrolled or once\ no more biometrics are enrolled, unless setInvalidatedByBiometricEnrollment(boolean) is used to allow validity after enrollment. 

Attempts to initialize cryptographic operations using such keys will throw KeyPermanentlyInvalidatedException.

So, after setting setUserAuthenticationRequired to True and adding a new fingerprint, the app fails and would require requesting a new secretKey:

at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:592)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:947) 
Caused by: android.security.keystore.KeyPermanentlyInvalidatedException: Key permanently invalidated

Secure Mobile Biometric Authentication in Swift (iOS):

The implementation in iOS is quite different from Android. We will use the Keychain to store the secretKey, and we enforce the usage of the Biometric authentication to access the item from the Keychain.

1- Create a biometry-protected keychain item:

We use SecAccessControlCreateWithFlags to create a SecAccessControl with the following parameters:

  • kSecAttrAccessibleWhenUnlockedThisDeviceOnly: our keychain entry can only be read when the iOS device is unlocked. Also it won’t be copied to other devices via iCloud and won’t be added to backups.
  • .biometryCurrentSet: sets the requirement of Touch ID or Face ID authentication. It strictly ties your entry to the currently enrolled biometric data.
static func getBioSecAccessControl() -> SecAccessControl {
       var access: SecAccessControl?
       var error: Unmanaged<CFError>?
           access = SecAccessControlCreateWithFlags(nil,
               kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
               .biometryCurrentSet,
               &error)
       precondition(access != nil, "SecAccessControlCreateWithFlags failed")
       return access!
   }


static func createBioProtectedEntry(key: String, data: Data) -> OSStatus {
       let query = [
           kSecClass as String: kSecClassGenericPassword as String,
           kSecAttrAccount as String: key,
           kSecAttrAccessControl as String: getBioSecAccessControl(),
           kSecValueData as String: data ] as CFDictionary
       return SecItemAdd(query as CFDictionary, nil)
   }

2- Read a biometry-protected entry:

static func loadBioProtected(key: String, context: LAContext? = nil,
                                prompt: String? = nil) -> Data? {

    var query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: key,
            kSecReturnData as String: kCFBooleanTrue,
            kSecAttrAccessControl as String: getBioSecAccessControl(),
            kSecMatchLimit as String: kSecMatchLimitOne ]

    if let context = context {
        query[kSecUseAuthenticationContext as String] = context
        query[kSecUseAuthenticationUI as String] = kSecUseAuthenticationUISkip
    }

    if let prompt = prompt {
        query[kSecUseOperationPrompt as String] = prompt
    }

    var dataTypeRef: AnyObject? = nil
    let status = SecItemCopyMatching(query as CFDictionary, &dataTypeRef)

    if status == noErr {
        return (dataTypeRef! as! Data)
    } else {
        return nil
    }
}

static func redBioProtectedEntry(entryName: String) {
    let authContext = LAContext()
    let accessControl = SecAccessControlCreateWithFlags(nil,
                kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
                .biometryCurrentSet,
                &error)
    authContext.evaluateAccessControl(accessControl, operation: .useItem, localizedReason: "Access sample keychain entry") {
        (success, error) in
        var result = ""
        if success, let data = loadBioProtected(key: entryName, context: authContext) {
            let result = String(decoding: data, as: UTF8.self)
        } else {
            result = "Can't read entry, error: \(error?.localizedDescription ?? "-")"
        }
    }
}

In this example, we use LAContext instance to authenticate the user. we call the authContext.evaluateAccessControl method prompting user for Touch ID or Face ID authentication. If the authentication succeeds we use our authContext instance to read the keychain entry contents.

The authContext instance is put into the query dictionary for the kSecUseAuthenticationContext key. This ensures that previously performed authentication is taken into account by the subsequent SecItemCopyMatching call.

What happens if I add a new fingerprint or delete an existing one after enabling biometric authentication in my application?

As explained in the Android chapter, we need to invalidate the access to the item from the keychain after changing the biometric data.

This is why it is recommended to use the setting SecAccessControlCreateFlags biometryCurrentSet which will automatically remove the biometry-protected entries after any change.

The flags userPresence , and biometryAny will keep the entry in the keychain, and the new biometric data remains considered valid and can therefore be accessed from the Keychain.

Secure Mobile Biometric Authentication in Flutter (Android and iOS):

We will use the Plugin biometric_storage for Flutter implementation. The plugin allows using biometric authentication to write and read encrypted data to the device.

The underhood implementation applies the abovementioned principles and uses CryptoObject for Android and a SecAccessControl with the right SecAccessControlCreateFlags to constraint access with Touch ID fo Face ID.

The plugin has a list of requirements to apply.

The first step is to create the access object where we will write and read the data after the biometric authentication:

/// Retrieves the given biometric storage file. Each store is completely separated and has its own encryption and biometric lock.
Future<BiometricStorageFile> _getStorageFile() async {
    final authStorage = await BiometricStorage().getStorage('authenticated_storage',options:StorageFileInitOptions(
      ///Always call it with `authenticationRequired=true`and`authenticationValidityDurationSeconds = -1` to ensure the secure implementation of bioùetric authentication. 
      authenticationValidityDurationSeconds: -1,
      authenticationRequired: true,
      androidBiometricOnly: true,
    ));
    return authStorage;
  }

Write data to the secure storage:

Future<void> createBioProtectedEntry(context) async {
    if (await _checkAuthenticate() == false) {
      showAlertDialog(context,const Text("Can't use biometric auth on this device."));
      return ;
    }
    _storageFile = await _getStorageFile();
    await _storageFile?.write(_my_secret_data);
  }

To read the data:

Future<void> redBioProtectedEntry(context) async {
    if (await _checkAuthenticate() == false) {
      showAlertDialog(context,const Text("Can't use biometric auth on this device."));
      return ;
    }
    if (_storageFile == null){
      showAlertDialog(context,const Text("Enable authentication first."));
      return ;
    }
    final data = await _storageFile?.read();
    showAlertDialog(context,Text(data!));
  }

Et Voila! The implementation works for both Android and iOS.

Every call to the _storageFile.write or _storageFile.read will prompt the user for Touch ID or Face ID authentication and the secretkey access is protected with the biometric binding.

If we check the Android implementation, when we call the authenticate function with a non-null cipher value and authenticationValidityDurationSeconds == -1, then the BiometricPrompt is called with the CryptoObject wrapping our cipher.

if (cipher == null || options.authenticationValidityDurationSeconds >= 0) {
    // if authenticationValidityDurationSeconds is not -1 we can't use a CryptoObject
    logger.debug { "Authenticating without cipher. ${options.authenticationValidityDurationSeconds}" }
    prompt.authenticate(promptBuilder.build())
} else {
    prompt.authenticate(promptBuilder.build(), BiometricPrompt.CryptoObject(cipher))
}

To get a non-null cipher, we need to have the same option passed authenticationValidityDurationSeconds == -1 Source code

val cipher = if (options.authenticationValidityDurationSeconds > -1) {
        null
    } else try {
        cipherForMode()
    } catch (e: KeyPermanentlyInvalidatedException) {
        // TODO should we communicate this to the caller?
        logger.warn(e) { "Key was invalidated. removing previous storage and recreating." }
        deleteFile()
        // if deleting fails, simply throw the second time around.
        cipherForMode()
    }

For iOS implementation, the security access control is defined with kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly and biometryCurrentSet Source code

private func accessControl(_ result: @escaping StorageCallback) -> SecAccessControl? {
    let accessControlFlags: SecAccessControlCreateFlags

    if #available(iOS 11.3, *) {
      accessControlFlags =  .biometryCurrentSet
    } else {
      accessControlFlags = .touchIDCurrentSet
    }

    var error: Unmanaged<CFError>?
    guard let access = SecAccessControlCreateWithFlags(
      nil, // Use the default allocator.
      kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
      accessControlFlags,
      &error) else {
hpdebug("Error while creating access control flags. \(String(describing: error))")
      result(storageError("writing data", "error writing data", "\(String(describing: error))"));
      return nil
    }

    return access
  }

The implementation is also using LAContext to read the protected data:

private func canAuthenticate(result: @escaping StorageCallback) {
    var error: NSError?
    let context = LAContext()

Source code

func read(_ result: @escaping StorageCallback, _ promptInfo: IOSPromptInfo) {

    guard var query = baseQuery(result) else {
      return;
    }
    query[kSecMatchLimit as String] = kSecMatchLimitOne
    query[kSecUseOperationPrompt as String] = promptInfo.accessTitle
    query[kSecReturnAttributes as String] = true
    query[kSecReturnData as String] = true
    query[kSecUseAuthenticationContext as String] = context

Conclusion

In this article, we went through a secure implementation of biometric authentication for the three main frameworks.

  • For Android, We used the CryptoObject to wrap our cipher and bind it to the biometric authentication to access the Android KeyStore key.

  • For iOS, We created a protected keychain item by using a SecAccessControl instance.

  • For Flutter, we used the plugin biometric_storage, that uses a secure Biometric implementation on Android and iOS to write and read data to a file.

For more information you can read the official documentation:

1- BiometricPrompt#authenticate

2- Accessing Keychain Items with Face ID or Touch ID

3- biometric_storage