L'authentification biométrique sur Android en 2026 : BiometricPrompt, le Keystore et le verrouillage d'une app local-first
Un guide pratique d'androidx.biometric sur Android — BiometricPrompt, clés adossées à un CryptoObject, repli sur les identifiants de l'appareil, et les erreurs qui font qu'une empreinte digitale ne protège rien.
Une invite d’empreinte digitale qui ne fait que verrouiller un écran n’est que du décor. Si les données sous-jacentes reposent tout ce temps dans une base Room en clair, n’importe qui disposant d’adb et d’un appareil rooté — ou d’un outil d’extraction de sauvegarde — les lit sans jamais toucher le capteur. Une vraie protection biométrique sur Android signifie que l’invite déverrouille une clé cryptographique, et c’est cette clé qui se dresse réellement entre un attaquant et les données. C’est la partie que la plupart des tutoriels BiometricPrompt sautent, et c’est la seule qui compte.
J’ai ajouté ceci à Granyn pour verrouiller les données budgétaires sur l’appareil, et le même schéma protège un journal de médicaments ou une liste d’abonnements dans n’importe quelle app local-first. Voici la version qui porte réellement le poids.
Deux choses différentes appelées « authentification biométrique »
androidx.biometric prend en charge deux modes d’authentification, qui protègent des choses complètement différentes :
- Authentification seule.
BiometricPrompt.authenticate(PromptInfo)sansCryptoObjectdemande simplement « la biométrie enregistrée correspond-elle ? » et renvoie oui ou non. C’est une porte d’interface. Rien n’est chiffré, rien n’est déchiffré — un appareil rooté ou un bug decontent providerla contourne entièrement, car les données n’ont jamais dépendu du succès de l’invite. - Authentification adossée à la crypto.
BiometricPrompt.authenticate(PromptInfo, CryptoObject)lie l’invite à unCipherconstruit à partir d’une clé Android Keystore marquéesetUserAuthenticationRequired(true). Le système lui-même refuse de laisser cette clé faire quoi que ce soit — chiffrer, déchiffrer, signer — tant qu’une authentification biométrique ou par identifiant de l’appareil correspondante n’a pas réussi juste avant. La clé devient physiquement inutilisable sans l’invite, pas seulement verrouillée par convention.
Si l’objectif est de « garder les chiffres du budget illisibles sans déverrouillage », seule la seconde option y parvient. La première convient pour quelque chose de moins critique, comme reconfirmer avant une action destructive sur un écran qui ne traite pas de données sensibles au repos.
Construire une clé Keystore qui exige une authentification
private const val KEY_ALIAS = "granyn_db_key"
fun getOrCreateKey(): SecretKey {
val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
keyStore.getKey(KEY_ALIAS, null)?.let { return it as SecretKey }
val keyGenerator = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore"
)
val spec = KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setUserAuthenticationRequired(true)
.setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG)
.build()
keyGenerator.init(spec)
return keyGenerator.generateKey()
}
setUserAuthenticationParameters(0, AUTH_BIOMETRIC_STRONG) est la ligne importante : une durée de 0 signifie que la clé n’est utilisable que pour exactement une opération par authentification, pas pour une fenêtre de temps glissante. C’est délibérément plus strict que l’ancien setUserAuthenticationValidityDurationSeconds, qui laissait une clé déverrouillée pendant N secondes après une vérification réussie — pratique, et aussi la raison pour laquelle beaucoup d’apps « protégées par biométrie » ne protégeaient plus rien discrètement dès qu’un service en arrière-plan touchait la clé une seconde fois. Exigez spécifiquement BIOMETRIC_STRONG ; BIOMETRIC_WEAK couvre des choses comme un déverrouillage par le visage sous un angle qu’une photo peut parfois tromper — ce n’est pas la barre que vous voulez pour protéger des données financières.
Relier l’invite au chiffreur
suspend fun authenticateAndDecrypt(
activity: FragmentActivity,
ciphertext: ByteArray,
iv: ByteArray,
): ByteArray = suspendCancellableCoroutine { cont ->
val cipher = Cipher.getInstance("AES/GCM/NoPadding").apply {
init(Cipher.DECRYPT_MODE, getOrCreateKey(), GCMParameterSpec(128, iv))
}
val callback = object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
val decrypted = result.cryptoObject?.cipher?.doFinal(ciphertext)
if (decrypted != null) cont.resume(decrypted) else cont.cancel()
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
cont.cancel()
}
}
val prompt = BiometricPrompt(activity, ContextCompat.getMainExecutor(activity), callback)
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle("Unlock Granyn")
.setSubtitle("Confirm it's you to view your budget")
.setAllowedAuthenticators(BIOMETRIC_STRONG or DEVICE_CREDENTIAL)
.build()
prompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
}
Deux points font trébucher ici. D’abord, setAllowedAuthenticators(BIOMETRIC_STRONG or DEVICE_CREDENTIAL) — combinant la biométrie forte avec le repli PIN/schéma/mot de passe de l’appareil — est mutuellement exclusif avec setNegativeButtonText(). Appeler les deux fait planter l’invite à la construction. L’option d’identifiant de l’appareil offre déjà aux utilisateurs une issue quand le capteur échoue ou que rien n’est enregistré, donc un bouton négatif manuel est redondant avec elle, pas complémentaire.
Ensuite, le CryptoObject que vous récupérez dans onAuthenticationSucceeded n’est pas une copie de courtoisie — c’est la même instance de Cipher, désormais déverrouillée, sur laquelle vous devez appeler doFinal() immédiatement. Reconstruire un Cipher neuf après le déclenchement du callback annule tout l’intérêt : ce cipher neuf n’a jamais été validé par le contrôle d’authentification, donc essayer de l’utiliser lève UserNotAuthenticatedException — ou pire, si vous l’avez construit sans l’exigence d’authentification, il fonctionne silencieusement et rouvre discrètement la faille que vous pensiez avoir fermée.
Vérifier la disponibilité avant même d’afficher l’invite
val biometricManager = BiometricManager.from(context)
when (biometricManager.canAuthenticate(BIOMETRIC_STRONG or DEVICE_CREDENTIAL)) {
BiometricManager.BIOMETRIC_SUCCESS -> { /* continuer */ }
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> {
// Ni empreinte/visage NI PIN/schéma/mot de passe configuré. Rediriger
// vers Settings.ACTION_BIOMETRIC_ENROLL, pas simplement appeler
// authenticate() et le laisser échouer silencieusement.
}
BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE,
BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> {
// Se replier sur un code PIN géré au niveau de l'app, ou désactiver
// simplement la fonction de verrouillage sur cet appareil plutôt que de
// bloquer l'accès.
}
else -> { /* gérer les autres codes d'erreur */ }
}
canAuthenticate() n’est pas du code passe-partout facultatif — c’est ce qui fait la différence entre une invite claire « configurez un verrouillage d’écran pour activer ceci » et un échec déroutant la première fois qu’un utilisateur sans identifiant enregistré active l’option. Testez ce chemin délibérément : effacez les empreintes enregistrées de l’émulateur (Paramètres → Sécurité → Empreinte digitale, ou dans les contrôles étendus de l’émulateur, le panneau Empreinte digitale) et vérifiez que l’app se dégrade proprement au lieu de planter.
Ce qui a réellement besoin du verrou
Tout ne bénéficie pas de ceci. Une clé adossée à la crypto ajoute une friction réelle — une authentification par déchiffrement, un aller-retour Keystore par lecture — donc verrouiller tout un chemin de lecture de base de données avec ça transforme « ouvrir l’app » en une opération nettement plus lente. Ce que j’ai retenu pour Granyn : chiffrer uniquement les champs qui comptent si le téléphone est perdu — soldes de comptes et notes de transaction — et laisser les noms de catégories et préférences d’interface dans des colonnes Room en clair. Le verrou protège ce qui est réellement sensible ; tout le reste reste rapide.
L’habitude à retenir de tout cela : avant de livrer une fonctionnalité « protégé par votre empreinte digitale », demandez-vous ce qui se passe si l’invite est entièrement contournée — root, un débogueur, un contournement de content provider. Si la réponse est « rien, la donnée reste lisible », l’invite n’a jamais fait le travail qu’elle semblait faire.
// À lire aussi
D’autres notes du journal
Health Connect sur Android en 2026 : lire et écrire des données sans renoncer au local-first
Un guide pratique de l'API Health Connect d'Android en 2026 — permissions, lectures en arrière-plan, et pourquoi une app local-first devrait la traiter comme optionnelle, pas comme une vérité absolue.
L'API In-App Review d'Android : demander une note sans être pénible
Un guide pratique de l'API In-App Review de Google — comment elle fonctionne réellement, quand la déclencher, et pourquoi la popup classique « notez-nous » nuit discrètement à votre note sur le Play Store.
Raccourcis d'application et tuiles de Paramètres rapides sur Android : enregistrer de l'eau sans ouvrir l'application
Un guide pratique de l'API dynamique ShortcutManager et de TileService sur Android — comment permettre à une action en un tap de contourner complètement l'application, avec les pièges qui font trébucher la plupart des implémentations.