Saltar al contenido
Todas las entradas

Autenticación biométrica en Android en 2026: BiometricPrompt, el Keystore y cómo bloquear una app local-first

Una guía práctica de androidx.biometric en Android — BiometricPrompt, claves respaldadas por CryptoObject, respaldo con credenciales del dispositivo, y los errores que hacen que una huella dactilar no proteja nada.

MFKAPPS 6 min de lectura

Un aviso de huella dactilar que solo bloquea una pantalla es decoración. Si los datos subyacentes están todo ese tiempo en una base de datos Room sin cifrar, cualquiera con adb y un dispositivo rooteado — o una herramienta de extracción de copias de seguridad — los lee sin tocar nunca el sensor. La protección biométrica real en Android significa que el aviso desbloquea una clave criptográfica, y esa clave es lo que realmente se interpone entre un atacante y los datos. Esta es la parte que la mayoría de los tutoriales de BiometricPrompt se saltan, y es la única que importa.

Añadí esto a Granyn para bloquear los datos de presupuesto en el dispositivo, y el mismo patrón protege un registro de medicación o una lista de suscripciones en cualquier app local-first. Esta es la versión que realmente sostiene el peso.

Dos cosas distintas llamadas “autenticación biométrica”

androidx.biometric admite dos modos de autenticación, y protegen cosas completamente diferentes:

  • Solo autenticación. BiometricPrompt.authenticate(PromptInfo) sin CryptoObject simplemente pregunta “¿coincidió la biometría registrada?” y responde sí o no. Es una puerta de interfaz. No se cifra ni se descifra nada — un dispositivo rooteado o un fallo de content provider lo elude por completo, porque los datos nunca dependieron de que el aviso tuviera éxito.
  • Autenticación respaldada por criptografía. BiometricPrompt.authenticate(PromptInfo, CryptoObject) ata el aviso a un Cipher construido a partir de una clave del Android Keystore marcada con setUserAuthenticationRequired(true). El propio sistema operativo se niega a dejar que esa clave haga nada — cifrar, descifrar, firmar — hasta que una autenticación biométrica o de credencial del dispositivo correspondiente acaba de tener éxito. La clave se vuelve físicamente inutilizable sin el aviso, no solo bloqueada por convención.

Si el objetivo es “mantener las cifras del presupuesto ilegibles sin desbloquear”, solo la segunda opción lo consigue. La primera está bien para algo de menor riesgo, como reconfirmar antes de una acción destructiva en una pantalla que no maneja datos sensibles en reposo.

Construir una clave de Keystore que requiera autenticación

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) es la línea importante: una duración de 0 significa que la clave se puede usar para exactamente una operación por autenticación, no para una ventana de tiempo continua. Esto es deliberadamente más estricto que el antiguo setUserAuthenticationValidityDurationSeconds, que dejaba una clave desbloqueada durante N segundos tras cualquier verificación exitosa — cómodo, y también la razón por la que muchas apps “protegidas con biometría” dejaban de proteger nada en silencio la segunda vez que un servicio en segundo plano tocaba la clave. Exige específicamente BIOMETRIC_STRONG; BIOMETRIC_WEAK cubre cosas como desbloquear con tu cara en un ángulo que a veces una foto puede engañar — no es el listón que quieres para proteger datos financieros.

Conectar el aviso al cifrador

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))
}

Aquí hay dos cosas con las que la gente tropieza. Primero, setAllowedAuthenticators(BIOMETRIC_STRONG or DEVICE_CREDENTIAL) — combinar biometría fuerte con el respaldo de PIN/patrón/contraseña del dispositivo — es mutuamente excluyente con setNegativeButtonText(). Llamar a ambos hace que el aviso lance una excepción al construirse. La opción de credencial del dispositivo ya le da a los usuarios una salida cuando el sensor falla o no hay nada registrado, así que un botón negativo manual es redundante con ella, no complementario.

Segundo, el CryptoObject que recibes de vuelta en onAuthenticationSucceeded no es una copia de cortesía — es la misma instancia de Cipher, ahora desbloqueada, sobre la que debes llamar a doFinal() de inmediato. Reconstruir un Cipher nuevo después de que se dispare el callback anula todo el propósito: ese cipher nuevo nunca fue bendecido por la comprobación de autenticación, así que intentar usarlo lanza UserNotAuthenticatedException — o peor, si lo construiste sin el requisito de autenticación, funciona en silencio y reabre calladamente el hueco que creías haber cerrado.

Comprobar la disponibilidad antes de mostrar el aviso

val biometricManager = BiometricManager.from(context)
when (biometricManager.canAuthenticate(BIOMETRIC_STRONG or DEVICE_CREDENTIAL)) {
    BiometricManager.BIOMETRIC_SUCCESS -> { /* continuar */ }
    BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> {
        // Ni huella/rostro NI PIN/patrón/contraseña configurados. Redirige a
        // Settings.ACTION_BIOMETRIC_ENROLL, no llames simplemente a
        // authenticate() y dejes que falle en silencio.
    }
    BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE,
    BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> {
        // Recurre a un PIN gestionado por la propia app, o simplemente omite
        // la función de bloqueo en este dispositivo en lugar de bloquear el acceso.
    }
    else -> { /* gestiona los códigos de error restantes */ }
}

canAuthenticate() no es código de relleno opcional — es la diferencia entre un aviso claro de “configura un bloqueo de pantalla para activar esto” y un fallo confuso la primera vez que un usuario sin credencial registrada toca el interruptor. Prueba este camino deliberadamente: borra las huellas registradas del emulador (Ajustes → Seguridad → Huella dactilar, o en los controles extendidos del emulador, el panel de Huella dactilar) y confirma que la app se degrada en lugar de fallar.

Qué es lo que realmente necesita el bloqueo

No todo se beneficia de esto. Una clave respaldada por criptografía añade fricción real — una autenticación por cada descifrado, un viaje de ida y vuelta al Keystore por cada lectura — así que bloquear toda una ruta de lectura de base de datos con esto convierte “abrir la app” en una operación notablemente más lenta. Lo que decidí para Granyn: cifrar solo los campos que importan si se pierde el teléfono — saldos de cuentas y notas de transacciones — y dejar los nombres de categorías y las preferencias de interfaz en columnas Room sin cifrar. El bloqueo protege lo que realmente es sensible; todo lo demás se mantiene rápido.

El hábito que vale la pena quedarse de todo esto: antes de lanzar cualquier función de “protegido con tu huella dactilar”, pregúntate qué pasa si el aviso se salta por completo — root, un depurador, una vulneración del content provider. Si la respuesta es “nada, los datos siguen siendo legibles”, el aviso nunca estuvo haciendo el trabajo que parecía hacer.