Inicio de sesión biométrico seguro en Kotlin, Swift y Flutter
En este artículo definimos una implementación segura de la autenticación biométrica móvil y ofrecemos implementaciones detalladas en los 3 principales lenguajes móviles modernos: Kotlin para Android, Swift para iOS y Dart para las aplicaciones multiplataforma de Flutter.
Introducción
Para mejorar la experiencia de usuario y la seguridad, la autenticación biométrica ha ganado una enorme popularidad. Sin embargo, con una gran comodidad llega una responsabilidad aún mayor: garantizar la seguridad de la implementación.
Este artículo explora la importancia de implementar de forma segura la autenticación biométrica móvil y ofrece implementaciones detalladas en los 3 principales lenguajes móviles modernos: Kotlin para Android, Swift para iOS y Dart para las aplicaciones multiplataforma de Flutter. Es un intento de abordar una brecha de seguridad que hemos observado en la mayoría de las aplicaciones móviles que hemos revisado.
Entonces, ¿qué es una autenticación biométrica móvil segura?
En primer lugar, una implementación segura de la autenticación biométrica móvil garantiza que sea necesario usar la autenticación con Face ID o Touch ID para acceder a los datos sensibles de la aplicación.
En las aplicaciones móviles, una implementación segura de la autenticación biométrica va más allá de verificar la huella dactilar o el rostro para iniciar sesión. También incluye cifrar (encrypting) los datos sensibles de la aplicación utilizando los datos biométricos.
Este cifrado añade una capa adicional de protección y hace extremadamente difícil que personas no autorizadas accedan a la información sensible o la utilicen. El cifrado con datos biométricos resulta crucial en caso de que un tercero no autorizado obtenga acceso al dispositivo, ya sea mediante malware o mediante acceso físico.
Sin cifrado, un atacante puede manipular la memoria para eludir la comprobación biométrica e iniciar sesión correctamente en la aplicación. Sin embargo, no podría interpretar ni utilizar los datos de la aplicación si el cifrado se utiliza junto con los datos biométricos. Esto ayuda a mantener la confidencialidad de la información sensible de la aplicación y, con ello, a proteger la privacidad y la seguridad de los datos de los usuarios.
Autenticación biométrica móvil segura
Escenario de la aplicación:
En este artículo implementaremos un ejemplo de autenticación biométrica segura para Kotlin, Swift y Flutter (Dart). El flujo de trabajo completo debe seguir estos pasos para habilitar la autenticación biométrica segura:
1- El usuario abre la aplicación móvil y se le presenta una pantalla de inicio de sesión.
2- El usuario introduce su nombre de usuario y su contraseña.
3- La aplicación verifica las credenciales proporcionadas enviándolas al servidor backend para su autenticación.
4- El servidor backend genera un token único para la sesión del usuario si las credenciales son válidas.
5- La aplicación solicita a los usuarios que configuren la autenticación biométrica (por ejemplo, huella dactilar o reconocimiento facial) para los inicios de sesión futuros.
6- La aplicación almacena el token del backend una vez que el usuario configura la autenticación biométrica.
7- El usuario inicia sesión correctamente y obtiene acceso a las funciones y funcionalidades de la aplicación.
8- En los inicios posteriores de la aplicación, esta comprueba si el usuario tiene habilitada la autenticación biométrica.
9- Si la autenticación biométrica está habilitada, la aplicación utiliza los datos biométricos para autenticar al usuario.
10- Tras una autenticación biométrica correcta, la aplicación recupera el token del backend del almacenamiento seguro.
Supondremos que el dispositivo tiene la biometría habilitada (por simplicidad, no incluiremos en el código las comprobaciones correspondientes).
También supondremos que los pasos del 1 al 5 ya se han completado y que el usuario está configurando la biometría para la aplicación, lo que requerirá cifrar el token con los datos biométricos y leerlo más adelante tras los inicios de sesión correctos.
Autenticación biométrica móvil segura en Kotlin (Android):
En Android, para almacenar el token del backend después de cifrarlo con datos biométricos, implementaremos lo siguiente:
1- Nuestra aplicación solicita al KeyStore de Android una SecretKey.
2- El Android Keystore crea la clave secreta en una ubicación segura (TEE).
3- El Keystore devuelve a nuestra aplicación un alias para acceder a la secretKey.
4- Creamos un objeto Cipher para realizar el cifrado y el descifrado (el cifrado se realiza en el sistema Keystore).
5- El sistema Keystore recibe el texto plano y el alias, y devuelve los datos cifrados, denominados texto cifrado.
6- Cuando la aplicación quiere realizar el descifrado, el sistema Keystore recibe el texto cifrado y el alias, y devuelve los datos descifrados o texto plano.
7- Habilitamos la autenticación biométrica para pedir al sistema que proteja la clave secreta mediante un enlace de autenticación.
8- Utilizamos un CryptoObject como envoltorio para transportar el cifrador.
9- Ciframos el token del backend con el cifrador envuelto en el CryptoObject. El CryptoObject se pasa como argumento a onAuthenticationSucceeded.
10- Leemos el token tras un inicio de sesión correcto descifrándolo con el cifrador envuelto en el CryptoObject.
Con esta implementación, aunque un dispositivo llegara a estar comprometido y un atacante realizara una solicitud, los datos permanecen cifrados, salvo que el atacante consiga de algún modo que el usuario se autentique con sus credenciales biométricas. La autenticación biométrica añade una capa adicional de seguridad, incluso en un dispositivo comprometido, porque no se puede acceder al Keystore gestionado por hardware a menos que el usuario esté presente.
Revise el código asociado a este flujo:
1- Cree una función para crear y obtener la SecretKey del 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- Creamos el objeto Cipher y lo envolvemos en el CryptoObject al llamar al método de autenticación:
// 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- Podemos implementar dos BiometricPrompt distintos, uno para cifrar los datos y otro para descifrarlos:
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- La última parte consiste en definir las funciones encryptData y decryptData. En este ejemplo, los datos sensibles son el token de autenticación del backend:
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"))
}

¿Qué ocurre si añado una nueva huella dactilar o elimino una existente después de habilitar la autenticación biométrica en mi aplicación?
Del mismo modo que en la autenticación por contraseña, donde debemos invalidar la sesión de token actual tras cambiar la contraseña, necesitamos invalidar el acceso a la SecretKey almacenada en el Android Keystore tras cambiar los datos biométricos.
En Android, este no es el comportamiento predeterminado, lo que significa que añadir una nueva huella dactilar permitirá al usuario iniciar sesión y acceder a los datos sensibles de la aplicación.
Para invalidar los datos tras el cambio, debemos establecer setUserAuthenticationRequired en true al generar la secretKey para el 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()
}
Según la documentación, establecer setUserAuthenticationRequired en True invalidará los datos existentes:
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.
Así, tras establecer setUserAuthenticationRequired en True y añadir una nueva huella dactilar, la aplicación falla y sería necesario solicitar una nueva 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
Autenticación biométrica móvil segura en Swift (iOS):
La implementación en iOS es bastante distinta de la de Android. Utilizaremos el Keychain para almacenar la secretKey y exigiremos el uso de la autenticación biométrica para acceder al elemento del Keychain.
1- Cree un elemento del keychain protegido por biometría:
Utilizamos SecAccessControlCreateWithFlags para crear un SecAccessControl con los siguientes parámetros:
kSecAttrAccessibleWhenUnlockedThisDeviceOnly: nuestra entrada del keychain solo puede leerse cuando el dispositivo iOS está desbloqueado. Además, no se copiará a otros dispositivos mediante iCloud ni se añadirá a las copias de seguridad..biometryCurrentSet: establece el requisito de autenticación con Touch ID o Face ID. Vincula estrictamente su entrada a los datos biométricos registrados actualmente.
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- Lea una entrada protegida por biometría:
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 ?? "-")"
}
}
}
En este ejemplo, utilizamos una instancia de LAContext para autenticar al usuario. Llamamos al método authContext.evaluateAccessControl, que solicita al usuario la autenticación con Touch ID o Face ID.
Si la autenticación tiene éxito, utilizamos nuestra instancia authContext para leer el contenido de la entrada del keychain.
La instancia authContext se coloca en el diccionario de la consulta bajo la clave kSecUseAuthenticationContext. Esto garantiza que la autenticación realizada previamente se tenga en cuenta en la llamada posterior a SecItemCopyMatching.
¿Qué ocurre si añado una nueva huella dactilar o elimino una existente después de habilitar la autenticación biométrica en mi aplicación?
Como se explicó en el capítulo de Android, necesitamos invalidar el acceso al elemento del keychain tras cambiar los datos biométricos.
Por eso se recomienda utilizar el ajuste SecAccessControlCreateFlags biometryCurrentSet, que eliminará automáticamente las entradas protegidas por biometría tras cualquier cambio.
Los indicadores userPresence y biometryAny mantendrán la entrada en el keychain, y los nuevos datos biométricos seguirán considerándose válidos y, por tanto, se podrá acceder desde el Keychain.
Autenticación biométrica móvil segura en Flutter (Android e iOS):
Utilizaremos el plugin biometric_storage para la implementación en Flutter. El plugin permite utilizar la autenticación biométrica para escribir y leer datos cifrados en el dispositivo.
La implementación subyacente aplica los principios mencionados anteriormente y utiliza CryptoObject en Android y un SecAccessControl con los SecAccessControlCreateFlags adecuados para restringir el acceso mediante Touch ID o Face ID.
El plugin tiene una lista de requisitos que deben cumplirse.
El primer paso es crear el objeto de acceso donde escribiremos y leeremos los datos tras la autenticación biométrica:
/// 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;
}
Escribir datos en el almacenamiento seguro:
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);
}
Para leer los datos:
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!));
}
¡Y listo! La implementación funciona tanto para Android como para iOS.
Cada llamada a _storageFile.write o _storageFile.read solicitará al usuario la autenticación con Touch ID o Face ID, y el acceso a la secretkey queda protegido mediante el enlace biométrico.
Si revisamos la implementación de Android, cuando llamamos a la función de autenticación con un valor de cipher no nulo y authenticationValidityDurationSeconds == -1, se llama a BiometricPrompt con el CryptoObject que envuelve nuestro cifrador.
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))
}
Para obtener un cipher no nulo, necesitamos pasar la misma opción authenticationValidityDurationSeconds == -1
Código fuente
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()
}
En la implementación de iOS, el control de acceso de seguridad se define con kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly y biometryCurrentSet
Código fuente
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
}
La implementación también utiliza LAContext para leer los datos protegidos:
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
Conclusión
En este artículo hemos recorrido una implementación segura de la autenticación biométrica para los tres principales frameworks.
-
En Android, utilizamos el
CryptoObjectpara envolver nuestrociphery vincularlo a la autenticación biométrica para acceder a la clave del Android KeyStore. -
En iOS, creamos un elemento protegido del keychain mediante una instancia de
SecAccessControl. -
En Flutter, utilizamos el plugin
biometric_storage, que emplea una implementación biométrica segura en Android e iOS para escribir y leer datos en un archivo.
Para obtener más información, puede consultar la documentación oficial:
1- BiometricPrompt#authenticate