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"))
}
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()
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 ourcipher
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
We do newsletters, too
Get the latest news, updates, and product innovations from Ostorlab right in your inbox.