wandres.dev
SEGURIDAD · Keystore y biometría

Biometría: BiometricPrompt y autenticación criptográfica

La huella del usuario no autentica nada por sí sola: autentica un booleano que tu propio proceso decide creerse. Esta lección desmonta la biometría de Android hasta su base: por qué el diálogo lo dibuja el sistema y no tú, qué separa una clase fuerte de una débil y por qué solo una puede sostener una clave, cómo se sustituye la comprobación booleana por una operación criptográfica que un atacante no puede falsificar, cómo se ata el resultado a un servidor mediante firma, y el catálogo de errores de diseño que convierten una integración aparentemente correcta en un adorno sin valor defensivo.

⏱ 24 min

Casi todas las integraciones biométricas que existen en producción comparten el mismo defecto estructural, y es tan fácil de cometer que resulta casi inevitable la primera vez: el código lanza el diálogo, recibe una devolución de llamada de éxito y a partir de ese instante ejecuta la rama privilegiada. El flujo es limpio, la interfaz es la del sistema, el usuario ve su huella reconocida y todo funciona. Y sin embargo la seguridad efectiva es nula, porque la decisión final la toma una comparación dentro de tu propio proceso, en un dispositivo que el atacante controla, en un método que se puede interceptar con herramientas públicas y quince minutos de trabajo. La biometría bien hecha no consiste en preguntar al sistema si el usuario es quien dice ser y creerse la respuesta; consiste en construir el flujo de manera que la respuesta afirmativa sea la única forma de obtener algo que no se puede fabricar de otro modo. La diferencia entre ambos diseños no se ve en la pantalla, no se ve en el código de un vistazo, y lo cambia absolutamente todo.

🎯 Al terminar esta lección sabrás
  • Explicar por qué BiometricPrompt es un diálogo del sistema y qué frontera de confianza establece esa propiedad.
  • Distinguir las clases biométricas fuerte y débil y justificar por qué solo una puede respaldar una clave criptográfica.
  • Sustituir la autenticación booleana por una operación con CryptoObject atada a una clave del Keystore.
  • Identificar y corregir los fallos de diseño habituales: caché del estado, retroceso silencioso y ausencia de verificación en servidor.

Un diálogo del sistema, no un sensor

La primera propiedad relevante de BiometricPrompt es negativa: tu aplicación no accede al sensor, no recibe la imagen de la huella ni la plantilla del rostro, y no participa en la comparación. Todo eso ocurre al otro lado de la frontera de hardware, en el mismo entorno confiable donde vive el Keystore, y el resultado que cruza de vuelta es la mínima información posible. Esta es la razón de que la API antigua basada en un gestor de huellas quedara obsoleta en la API 28: no bastaba con cambiar de clase, había que cambiar de modelo.

De ahí se deriva que la interfaz la dibuje el sistema y no tú. No puedes personalizar el diálogo más allá del título, el subtítulo, la descripción y el texto del botón negativo, y esa limitación no es un descuido de la biblioteca: si pudieras dibujarlo, podrías imitarlo, y un diálogo biométrico falsificado por una aplicación cualquiera sería una primitiva de suplantación devastadora. La imposibilidad de personalizarlo es la garantía de que lo que el usuario ve procede del sistema.

Antes de lanzar nada hay que preguntar qué es posible en este dispositivo concreto, porque el parque es enormemente heterogéneo y las respuestas negativas son informativas: puede no haber hardware, puede haberlo pero estar temporalmente indisponible, y puede estar disponible pero sin ninguna credencial inscrita, caso en el que corresponde enviar al usuario a inscribirla en lugar de degradar en silencio.

val gestor = BiometricManager.from(context)
val requisito = BIOMETRIC_STRONG          // no aceptamos clase debil

when (gestor.canAuthenticate(requisito)) {
    BiometricManager.BIOMETRIC_SUCCESS -> lanzarPrompt()
    BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> pedirInscripcion()
    BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE,
    BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED ->
        usarCredencialDelDispositivo()
    else -> reintentarMasTarde()
}

Clases fuertes, clases débiles y lo que cada una sostiene

Android clasifica las modalidades biométricas por tasa de aceptación de impostores y por resistencia a ataques de presentación. La clase fuerte, históricamente llamada clase 3, cumple umbrales estrictos y tiene su comparación anclada en el entorno confiable; la clase débil, la clase 2, es más permisiva y suele corresponder al reconocimiento facial basado únicamente en la cámara frontal, que un fabricante puede implementar sin sensor de profundidad.

La consecuencia operativa es tajante y no admite matices: solo la clase fuerte puede desbloquear una clave del Keystore. No es una política de la biblioteca que se pueda relajar con una bandera, es una propiedad del propio subsistema criptográfico, y explica por qué una integración que funciona con huella falla en un dispositivo donde el usuario solo ha inscrito su rostro. Diseñar sin contemplarlo produce informes de fallo irreproducibles procedentes de un fabricante concreto.

🛡️

Clase fuerte

Umbrales estrictos y comparación anclada en hardware. Es la única que puede respaldar una clave y la única aceptable para autorizar operaciones sensibles.

⚠️

Clase débil

Comodidad de desbloqueo con garantías menores. Aceptable para ocultar contenido de una vista previa; nunca para autorizar un pago o revelar una credencial.

Existe además el retroceso a la credencial del dispositivo, es decir, el patrón o el código de bloqueo. Combinarlo con biometría es razonable y a menudo obligatorio por accesibilidad, pero debe ser una decisión explícita y consciente: al permitirlo, la garantía efectiva de tu flujo pasa a ser la del factor más débil que aceptas, y ese factor lo elige el usuario en el momento, no tú en tiempo de diseño.

La autenticación que no se puede falsificar

Aquí está el núcleo de la lección. En el diseño booleano, el éxito de la autenticación es un mensaje que tu proceso recibe y decide obedecer; en el diseño criptográfico, el éxito es el hecho de que una clave del Keystore se haya vuelto utilizable, y eso ocurre dentro del hardware, fuera del alcance de cualquier manipulación del código de tu aplicación.

La construcción tiene dos mitades. Primero se genera la clave declarando que exige autenticación del usuario, con un tiempo de validez de cero, lo que significa que cada operación individual necesita una autenticación propia y reciente. Segundo, el objeto Cipher o Signature inicializado con esa clave se entrega al diálogo envuelto en un CryptoObject, y solo si el usuario se autentica correctamente el sistema devuelve ese mismo objeto ya habilitado dentro del resultado. Un atacante que intercepte la devolución de llamada de éxito consigue exactamente nada, porque no tiene el objeto criptográfico funcional y no puede fabricarlo.

fun crearClaveAtada(): SecretKey {
    val generador = KeyGenerator.getInstance("AES", "AndroidKeyStore")
    generador.init(
        KeyGenParameterSpec.Builder("clave_biometrica", PURPOSE_ENCRYPT or PURPOSE_DECRYPT)
            .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
            .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
            .setUserAuthenticationRequired(true)
            // Cero segundos: una autenticacion por cada operacion, sin ventana.
            .setUserAuthenticationParameters(0, AUTH_BIOMETRIC_STRONG)
            // Inscribir una huella nueva invalida la clave: es lo que queremos.
            .setInvalidatedByBiometricEnrollment(true)
            .build(),
    )
    return generador.generateKey()
}

fun pedirAutorizacion(actividad: FragmentActivity, cipher: Cipher) {
    val prompt = BiometricPrompt(
        actividad,
        ContextCompat.getMainExecutor(actividad),
        object : BiometricPrompt.AuthenticationCallback() {
            override fun onAuthenticationSucceeded(r: BiometricPrompt.AuthenticationResult) {
                // El valor esta aqui, no en el hecho de que nos hayan llamado.
                val habilitado = requireNotNull(r.cryptoObject?.cipher)
                revelarCredencial(habilitado)
            }
            override fun onAuthenticationError(codigo: Int, mensaje: CharSequence) {
                manejarError(codigo)
            }
        },
    )
    val info = BiometricPrompt.PromptInfo.Builder()
        .setTitle("Confirma tu identidad")
        .setNegativeButtonText("Cancelar")
        .setAllowedAuthenticators(BIOMETRIC_STRONG)
        .setConfirmationRequired(true)   // imprescindible en modalidades pasivas
        .build()

    prompt.authenticate(info, BiometricPrompt.CryptoObject(cipher))
}
flowchart TD
A[El usuario pide una operacion sensible] --> B[Consultar disponibilidad de clase fuerte]
B -->|sin inscripcion| C[Enviar a inscribir credencial]
B -->|disponible| D[Inicializar cipher con clave del Keystore]
D -->|clave invalidada| E[Borrar alias y rehacer registro]
D -->|correcto| F[Lanzar prompt con objeto criptografico]
F -->|error de bloqueo| G[Degradar a credencial remota]
F -->|exito| H[Usar el objeto devuelto por el sistema]
H --> I[Firmar reto del servidor]
I --> J[El servidor verifica la firma]

Cuando la autenticación no protege un dato local sino una acción en el servidor, la variante correcta usa una clave asimétrica y una firma. El servidor emite un reto aleatorio, el cliente lo firma con la clave privada atada a biometría, y el servidor verifica la firma contra la clave pública que registró durante el alta. Así el servidor obtiene una prueba criptográfica de que hubo autenticación local en el dispositivo enrolado, en lugar de un mensaje que dice que la hubo. La diferencia entre ambas cosas es la totalidad del valor de seguridad del mecanismo.

Los fallos de diseño que se repiten

El primero y más extendido ya está descrito: creer al booleano. El segundo es cachear el estado de autenticación en una variable de sesión que sobrevive minutos u horas, con lo que la protección desaparece para quien tenga el dispositivo desbloqueado en la mano; la ventana de validez debe declararse en la clave y dejar que el sistema la aplique, no gestionarse con un temporizador propio.

El tercero es tratar el bloqueo temporal como un fallo genérico. Tras varios intentos fallidos el sistema devuelve un error de bloqueo, y transcurrido más tiempo uno de bloqueo permanente que solo se resuelve con la credencial del dispositivo. Un flujo que en ese punto se limita a mostrar un mensaje de error deja al usuario encerrado fuera de su propia cuenta, y la ruta de recuperación tiene que existir y pasar por el servidor.

El cuarto es olvidar la confirmación explícita en modalidades pasivas. Un reconocimiento facial se dispara con solo mirar el teléfono, de modo que sin exigir confirmación es posible autorizar una operación sensible sin ninguna intención por parte del usuario, apuntando el dispositivo a su cara durante un segundo. El quinto, más silencioso, es permitir la credencial del dispositivo como retroceso automático en flujos que se documentaron como biométricos, degradando la garantía sin que nadie tome la decisión conscientemente.

La biometría no autentica: autoriza el uso de un secreto que ya tenías

Hay una confusión de categorías en el centro de casi todos los diseños biométricos defectuosos, y desactivarla ordena el resto del problema por sí sola. La biometría no es un factor de autenticación remota y no puede serlo, porque el servidor nunca ve la huella, nunca ve el rostro, nunca ve la comparación y no tiene forma alguna de distinguir un dispositivo honesto que informa de un éxito legítimo de un cliente modificado que informa de un éxito inventado. Lo que la biometría hace de verdad es mucho más modesto y mucho más útil una vez enunciado con precisión: es un gesto local que autoriza el uso de un secreto que ya estaba en el dispositivo, previamente establecido mediante una autenticación real, y que permanecía criptográficamente inaccesible hasta que ese gesto ocurrió. La huella no demuestra quién eres ante nadie; desbloquea una llave que se te entregó cuando sí demostraste quién eras, mediante contraseña, código de un solo uso o el mecanismo que corresponda. Por eso el patrón correcto siempre tiene dos tiempos separados por semanas o meses: un alta en la que se establece confianza por otros medios y se genera un par de claves atado a la biometría cuya parte pública se registra en el servidor, y un uso posterior en el que el gesto habilita la firma de un reto. En el primer tiempo se construye la confianza; en el segundo se gasta. Un diseño que carece del primer tiempo no es un diseño biométrico débil, es un diseño sin autenticación al que se le ha añadido una animación de huella dactilar, y su nivel de seguridad real es el de una aplicación que no pide nada. Este encuadre también resuelve de inmediato preguntas que parecían abiertas: qué hacer cuando el usuario inscribe una huella nueva, y la respuesta es invalidar, porque la confianza se estableció con un conjunto de credenciales que ha cambiado; qué hacer cuando el hardware falta o queda bloqueado, y la respuesta es volver al mecanismo del primer tiempo, que sigue existiendo y sigue siendo la verdadera raíz; y qué enviar al servidor tras un éxito, que jamás es una noticia sino una prueba que el servidor pueda verificar por sí mismo sin confiar en el mensajero.

⚔️ Convierte una biometría decorativa en una real
  1. Localiza en tu código toda devolución de llamada de éxito biométrico y anota cuáles derivan en una simple asignación booleana.
  2. Genera una clave atada a autenticación con validez de cero segundos e invalidación al inscribir nueva biometría.
  3. Reescribe el flujo para que el valor protegido solo se obtenga a través del objeto criptográfico devuelto por el sistema.
  4. Añade una variante asimétrica que firme un reto emitido por tu servidor y verifica esa firma en el lado del servidor.
  5. Fuerza el bloqueo por intentos fallidos y documenta la ruta de recuperación completa, comprobando que no deja al usuario encerrado.