Connexion biométrique sécurisée en Kotlin, Swift et Flutter
Dans cet article, nous définissons une implémentation sécurisée de l'authentification biométrique mobile et en proposons des implémentations détaillées dans les 3 principaux langages mobiles modernes : Kotlin pour Android, Swift pour iOS et Dart pour les applications multiplateformes Flutter.
Introduction
L'authentification biométrique s'est largement répandue pour améliorer l'expérience utilisateur comme la sécurité. Mais plus la commodité est grande, plus la responsabilité de garantir la sécurité de l'implémentation l'est aussi.
Cet article explore l'importance d'une implémentation sécurisée de l'authentification biométrique mobile et en propose des implémentations détaillées dans les 3 principaux langages mobiles modernes : Kotlin pour Android, Swift pour iOS et Dart pour les applications multiplateformes Flutter. Il s'agit d'une tentative de combler une lacune de sécurité que nous avons constatée dans la plupart des applications mobiles que nous avons examinées.
Qu'est-ce qu'une authentification biométrique mobile sécurisée ?
Avant tout, une implémentation sécurisée de l'authentification biométrique mobile impose de recourir à Face ID ou à Touch ID pour accéder aux données sensibles de l'application.
Dans les applications mobiles, une implémentation sécurisée de l'authentification biométrique va au-delà de la simple vérification de l'empreinte digitale ou du visage pour se connecter. Elle inclut aussi le chiffrement (encrypting) des données sensibles de l'application à l'aide des données biométriques.
Ce chiffrement ajoute une couche de protection supplémentaire, qui rend très difficile l'accès aux informations sensibles, ou leur utilisation, par des personnes non autorisées. Le chiffrement à l'aide des données biométriques devient crucial lorsqu'une partie non autorisée accède à l'appareil, par un malware ou par un accès physique.
Sans chiffrement, un attaquant peut manipuler la mémoire pour contourner la vérification biométrique et se connecter avec succès à l'application. En revanche, il ne pourrait ni interpréter ni utiliser les données de l'application si le chiffrement repose sur les données biométriques. Cela contribue à préserver la confidentialité des informations sensibles de l'application, et donc à protéger la vie privée et la sécurité des données des utilisateurs.
Authentification biométrique mobile sécurisée
Scénario d'application :
Dans cet article, nous allons implémenter un exemple d'authentification biométrique sécurisée pour Kotlin, Swift et Flutter (Dart). Le workflow complet doit suivre les étapes ci-dessous pour activer l'authentification biométrique sécurisée :
1- L'utilisateur ouvre l'application mobile et se voit présenter un écran de connexion.
2- L'utilisateur saisit son nom d'utilisateur et son mot de passe.
3- L'application vérifie les identifiants fournis en les envoyant au serveur backend pour authentification.
4- Le serveur backend génère un token unique pour la session de l'utilisateur si les identifiants sont valides.
5- L'application invite l'utilisateur à configurer l'authentification biométrique (par exemple empreinte digitale ou reconnaissance faciale) pour ses prochaines connexions.
6- L'application stocke le token du backend une fois que l'utilisateur a configuré l'authentification biométrique.
7- L'utilisateur est connecté avec succès et accède aux fonctionnalités de l'application.
8- Lors des lancements suivants de l'application, celle-ci vérifie si l'utilisateur a activé l'authentification biométrique.
9- Si l'authentification biométrique est activée, l'application utilise les données biométriques pour authentifier l'utilisateur.
10- Une fois l'authentification biométrique réussie, l'application récupère le token du backend dans le stockage sécurisé.
Nous supposerons que la biométrie est activée sur l'appareil (par souci de simplicité, nous n'incluons pas ces vérifications dans le code).
Nous supposerons également que les étapes 1 à 5 sont terminées et que l'utilisateur configure la biométrie pour l'application, ce qui nécessitera de chiffrer le token avec les données biométriques et de le lire ensuite après les connexions réussies.
Authentification biométrique mobile sécurisée en Kotlin (Android) :
Sous Android, pour stocker le token du backend après l'avoir chiffré avec les données biométriques, nous allons procéder comme suit :
1- Notre application demande une SecretKey au KeyStore d'Android.
2- Le Keystore d'Android crée la clé secrète dans un emplacement sécurisé (TEE).
3- Le Keystore renvoie à notre application un alias permettant d'accéder à la secretKey.
4- Nous créons un objet Cipher pour effectuer le chiffrement et le déchiffrement (le chiffrement est réalisé dans le système Keystore).
5- Le système Keystore reçoit le texte en clair et l'alias, et renvoie des données chiffrées appelées texte chiffré.
6- Lorsque l'application veut effectuer un déchiffrement, le système Keystore reçoit le texte chiffré et l'alias, et renvoie les données déchiffrées, c'est-à-dire le texte en clair.
7- Nous activons l'authentification biométrique pour demander au système de sécuriser la clé secrète au moyen d'une liaison d'authentification.
8- Nous utilisons un CryptoObject comme enveloppe pour transporter le cipher.
9- Nous chiffrons le token du backend avec le cipher encapsulé dans le CryptoObject. Le CryptoObject est passé en argument à onAuthenticationSucceeded.
10- Nous lisons le token après une connexion réussie en le déchiffrant avec le cipher encapsulé dans le CryptoObject.
Avec cette implémentation, même si un appareil est compromis et qu'un attaquant envoie une requête, les données restent chiffrées, sauf si l'attaquant parvient d'une manière ou d'une autre à faire authentifier l'utilisateur avec ses identifiants biométriques. L'authentification biométrique ajoute une couche de sécurité supplémentaire, même sur un appareil compromis, car le Keystore géré par le matériel n'est pas accessible tant que l'utilisateur n'est pas présent.
Voici le code associé à ce flux :
1- Créez une fonction pour créer et obtenir la SecretKey depuis l'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- Nous créons l'objet Cipher et nous l'encapsulons dans le CryptoObject lors de l'appel de la méthode authenticate :
// 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- Nous pouvons implémenter deux BiometricPrompt différents, un pour chiffrer les données et un pour les déchiffrer :
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 dernière partie consiste à définir les fonctions encryptData et decryptData. Dans cet exemple, la donnée sensible est le token d'authentification du 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"))
}

Que se passe-t-il si j'ajoute une nouvelle empreinte digitale ou si j'en supprime une après avoir activé l'authentification biométrique dans mon application ?
Comme pour l'authentification par mot de passe, où il faut invalider la session de token en cours après un changement de mot de passe, nous devons invalider l'accès à la SecretKey stockée dans l'Android Keystore après une modification des données biométriques.
Sous Android, ce n'est pas le comportement par défaut : l'ajout d'une nouvelle empreinte digitale permettra donc à l'utilisateur de se connecter et d'accéder aux données sensibles de l'application.
Pour invalider les données après la modification, nous devons définir setUserAuthenticationRequired sur true lors de la génération de la secretKey pour l'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()
}
D'après la documentation, définir setUserAuthenticationRequired sur True invalidera les données existantes :
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.
Ainsi, après avoir défini setUserAuthenticationRequired sur True et ajouté une nouvelle empreinte digitale, l'application échoue et il faudrait demander une nouvelle 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
Authentification biométrique mobile sécurisée en Swift (iOS) :
L'implémentation sous iOS est assez différente de celle d'Android. Nous allons utiliser le Keychain pour stocker la secretKey, et nous imposons l'authentification biométrique pour accéder à l'élément du Keychain.
1- Créez un élément du Keychain protégé par la biométrie :
Nous utilisons SecAccessControlCreateWithFlags pour créer un SecAccessControl avec les paramètres suivants :
kSecAttrAccessibleWhenUnlockedThisDeviceOnly: notre entrée du Keychain ne peut être lue que lorsque l'appareil iOS est déverrouillé. Elle ne sera pas non plus copiée vers d'autres appareils via iCloud et ne sera pas ajoutée aux sauvegardes..biometryCurrentSet: impose une authentification par Touch ID ou Face ID. Elle lie strictement votre entrée aux données biométriques actuellement enregistrées.
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- Lisez une entrée protégée par la biométrie :
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 ?? "-")"
}
}
}
Dans cet exemple, nous utilisons une instance de LAContext pour authentifier l'utilisateur. Nous appelons la méthode authContext.evaluateAccessControl, qui demande à l'utilisateur de s'authentifier par Touch ID ou Face ID.
Si l'authentification réussit, nous utilisons notre instance authContext pour lire le contenu de l'entrée du Keychain.
L'instance authContext est placée dans le dictionnaire de requête sous la clé kSecUseAuthenticationContext. Cela garantit que l'authentification effectuée précédemment est prise en compte par l'appel suivant à SecItemCopyMatching.
Que se passe-t-il si j'ajoute une nouvelle empreinte digitale ou si j'en supprime une après avoir activé l'authentification biométrique dans mon application ?
Comme expliqué dans le chapitre sur Android, nous devons invalider l'accès à l'élément du Keychain après une modification des données biométriques.
C'est pourquoi il est recommandé d'utiliser le paramètre SecAccessControlCreateFlags biometryCurrentSet, qui supprime automatiquement les entrées protégées par la biométrie après toute modification.
Les flags userPresence et biometryAny conservent l'entrée dans le Keychain ; les nouvelles données biométriques restent considérées comme valides et peuvent donc accéder au Keychain.
Authentification biométrique mobile sécurisée en Flutter (Android et iOS) :
Nous utiliserons le plugin biometric_storage pour l'implémentation Flutter. Le plugin permet d'utiliser l'authentification biométrique pour écrire et lire des données chiffrées sur l'appareil.
L'implémentation sous-jacente applique les principes évoqués plus haut et utilise CryptoObject pour Android et un SecAccessControl avec les bons SecAccessControlCreateFlags pour contraindre l'accès par Touch ID ou Face ID.
Le plugin a une liste de prérequis à respecter.
La première étape consiste à créer l'objet d'accès dans lequel nous écrirons et lirons les données après l'authentification biométrique :
/// 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;
}
Écrire des données dans le stockage sécurisé :
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);
}
Pour lire les données :
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 voilà ! L'implémentation fonctionne à la fois sous Android et sous iOS.
Chaque appel à _storageFile.write ou _storageFile.read invitera l'utilisateur à s'authentifier par Touch ID ou Face ID, et l'accès à la secretkey est protégé par la liaison biométrique.
Si nous examinons l'implémentation Android, lorsque nous appelons la fonction authenticate avec une valeur cipher non nulle et authenticationValidityDurationSeconds == -1, le BiometricPrompt est appelé avec le CryptoObject qui encapsule notre 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))
}
Pour obtenir un cipher non nul, il faut que la même option soit passée avec authenticationValidityDurationSeconds == -1
Code source
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()
}
Pour l'implémentation iOS, le contrôle d'accès de sécurité est défini avec kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly et biometryCurrentSet
Code source
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
}
L'implémentation utilise également LAContext pour lire les données protégées :
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
Dans cet article, nous avons parcouru une implémentation sécurisée de l'authentification biométrique pour les trois principaux frameworks.
-
Pour Android, nous avons utilisé le
CryptoObjectpour encapsuler notrecipheret le lier à l'authentification biométrique afin d'accéder à la clé de l'Android KeyStore. -
Pour iOS, nous avons créé un élément de Keychain protégé en utilisant une instance de
SecAccessControl. -
Pour Flutter, nous avons utilisé le plugin
biometric_storage, qui s'appuie sur une implémentation biométrique sécurisée sous Android et iOS pour écrire et lire des données dans un fichier.
Pour plus d'informations, vous pouvez consulter la documentation officielle :
1- BiometricPrompt#authenticate