wandres.dev
SEGURIDAD · Keystore y biometría

Android Keystore: claves que nunca salen del hardware

El Keystore de Android no es un almacén de claves: es un servicio que ejecuta operaciones criptográficas en tu nombre sin entregarte nunca el material con el que las hace. Esta lección desarrolla las consecuencias completas de ese diseño: la indirección por identificador que sustituye a la clave en memoria, la diferencia real entre respaldo por entorno de ejecución confiable y elemento seguro dedicado, la atestación como única prueba verificable de dónde vive una clave, el cifrado correcto de datos locales con vectores de inicialización que nadie reutiliza, y el catálogo de invalidaciones que convierte una clave perfectamente válida en un error irrecuperable.

⏱ 25 min

Toda la criptografía aplicada de un cliente móvil se reduce, después de retirar el ruido, a un solo problema sin solución local: dónde guardas la clave. Cifrar un fichero con AES es trivial y lleva resuelto cuarenta años; lo que nunca estuvo resuelto es que la clave necesaria para descifrarlo tiene que estar disponible en el mismo dispositivo que contiene el fichero, lo que a primera vista convierte el ejercicio entero en un candado cuya llave está pegada a la puerta. Derivarla de una contraseña traslada el problema al usuario, incrustarla en el binario lo traslada al ingeniero inverso, y guardarla en un fichero no lo traslada a ninguna parte. El Keystore de Android resuelve esto de la única manera en que se puede resolver, que no es escondiendo mejor la clave sino sacándola por completo del espacio de direcciones de tu proceso: el material vive detrás de una frontera de hardware, tu código recibe un identificador opaco, y cada operación criptográfica se ejecuta al otro lado. Nunca ves la clave. Y precisamente porque nunca la ves, tampoco puede verla quien vuelque tu memoria.

🎯 Al terminar esta lección sabrás
  • Explicar la indirección del Keystore y por qué un identificador opaco es una primitiva de seguridad y no una molestia de la API.
  • Distinguir el respaldo por entorno de ejecución confiable del respaldo por elemento seguro y saber cuándo exigir cada uno.
  • Generar y usar claves con KeyGenParameterSpec aplicando correctamente propósitos, modos y vectores de inicialización.
  • Anticipar y gestionar la invalidación de claves para que una rotación de credenciales no destruya los datos del usuario.

La clave que no puedes leer ni tú

Cuando pides al Keystore que genere una clave, esa clave se crea al otro lado de una frontera de confianza y jamás cruza de vuelta en claro. Lo que tu proceso recibe es una referencia con un alias, y cuando llamas a un objeto Cipher inicializado con esa referencia, lo que ocurre por debajo es una llamada al servicio del sistema que ejecuta la operación con el material real y devuelve únicamente el resultado. La consecuencia práctica es contundente: un volcado de la memoria de tu proceso, un depurador conectado o una vulnerabilidad de lectura arbitraria no revelan la clave, porque la clave nunca estuvo ahí.

Esa indirección explica todas las rarezas de la API. No puedes serializar una clave del Keystore, no puedes copiarla a otro dispositivo, no puedes incluirla en una copia de seguridad y no puedes recuperarla si el usuario restablece el dispositivo. Cada una de esas limitaciones es exactamente la propiedad de seguridad por la que existe el mecanismo, y tratarlas como obstáculos a sortear equivale a desmontar la garantía entera.

La generación se declara con una especificación que fija de antemano todo lo que la clave podrá hacer, y esa declaración es vinculante: el propio Keystore rechaza cualquier uso fuera de lo especificado. Si generas una clave solo para cifrar, ningún fallo posterior de tu código conseguirá usarla para firmar. Es un caso poco frecuente de API en la que restringir por adelantado no cuesta nada y protege mucho.

private const val ALIAS = "clave_datos_locales"

fun crearClave(): SecretKey {
    val generador = KeyGenerator.getInstance(
        KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore",
    )
    val spec = KeyGenParameterSpec.Builder(
        ALIAS,
        KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
    )
        .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
        .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
        .setKeySize(256)
        .setRandomizedEncryptionRequired(true)
        .build()

    generador.init(spec)
    return generador.generateKey()
}

fun obtenerClave(): SecretKey {
    val almacen = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
    return (almacen.getEntry(ALIAS, null) as KeyStore.SecretKeyEntry).secretKey
}

Entorno confiable, elemento seguro y atestación

El respaldo por hardware admite dos niveles con propiedades muy distintas. El entorno de ejecución confiable es un modo privilegiado del mismo procesador de aplicaciones, con su propia memoria aislada y un sistema operativo mínimo separado del principal. Protege frente a un sistema comprometido, incluido un kernel comprometido, pero comparte silicio con el resto y por tanto comparte también toda una familia de ataques de canal lateral y de ejecución especulativa.

El elemento seguro dedicado, expuesto desde la API 28, es un chip físicamente separado con su propia unidad de proceso, su propia memoria y su propia resistencia a manipulación física. Se solicita con una sola llamada al construir la especificación, y cuando el dispositivo no lo tiene la generación falla con una excepción que debes capturar para reintentar sin él. Su coste es real: las operaciones son notablemente más lentas y el conjunto de algoritmos soportados es más reducido, de modo que exigirlo indiscriminadamente para cifrar una caché es un error de ingeniería, no una precaución.

🔐

Entorno confiable

Disponible en la práctica totalidad del parque. Resiste a un sistema operativo comprometido y es el objetivo razonable por defecto para datos de sesión y credenciales.

🧿

Elemento seguro

Chip separado con resistencia física a manipulación. Reservado para claves de identidad de larga vida y material cuya exposición sería catastrófica.

La pregunta que ninguna de las dos opciones responde por sí sola es cómo sabe tu servidor que la clave con la que se firmó una petición vive de verdad en hardware y no en una implementación de software de un emulador. Esa es la función de la atestación de claves: el Keystore emite una cadena de certificados firmada por una clave de raíz provisionada por el fabricante, y en la extensión de ese certificado viaja una descripción verificable de la clave, incluyendo su nivel de seguridad, si el arranque verificado estaba activo y qué paquete la generó. La verificación tiene que ocurrir en el servidor y con la raíz de Google fijada, porque una atestación validada por el propio cliente no prueba absolutamente nada.

sequenceDiagram
participant App as Proceso de la app
participant KS as Servicio Keystore
participant HW as Hardware seguro
App->>KS: generar clave con alias y reto
KS->>HW: crear material y firmar atestacion
HW-->>KS: cadena de certificados
KS-->>App: alias opaco mas cadena
App->>KS: cifrar bloque de datos
KS->>HW: operacion con la clave real
HW-->>KS: texto cifrado
KS-->>App: resultado sin clave

Cifrar datos locales sin equivocarse

El modo autenticado con datos asociados es la única elección defendible hoy, porque proporciona a la vez confidencialidad e integridad, y esa segunda propiedad es la que impide que un atacante con acceso al fichero modifique el texto cifrado para provocar un descifrado válido pero distinto. Los modos sin autenticación siguen disponibles en la API y siguen siendo un error en código nuevo.

El punto exacto donde se rompen la mayoría de las implementaciones caseras es el vector de inicialización. En modo GCM, reutilizar un vector con la misma clave no degrada la seguridad de forma gradual: la destruye por completo y de forma inmediata, permitiendo recuperar el texto en claro de ambos mensajes. La disciplina correcta es no elegirlo nunca a mano. Deja que el objeto Cipher lo genere, recupéralo después de inicializar y guárdalo junto al texto cifrado, que no es secreto y no necesita protección.

class CofreLocal(private val clave: SecretKey) {

    fun cifrar(datos: ByteArray): ByteArray {
        val cipher = Cipher.getInstance("AES/GCM/NoPadding")
        cipher.init(Cipher.ENCRYPT_MODE, clave)   // el IV lo genera el proveedor
        val cuerpo = cipher.doFinal(datos)
        return cipher.iv + cuerpo                 // IV al frente, no es secreto
    }

    fun descifrar(bloque: ByteArray): ByteArray {
        val iv = bloque.copyOfRange(0, 12)
        val cuerpo = bloque.copyOfRange(12, bloque.size)
        val cipher = Cipher.getInstance("AES/GCM/NoPadding")
        cipher.init(Cipher.DECRYPT_MODE, clave, GCMParameterSpec(128, iv))
        return cipher.doFinal(cuerpo)             // lanza si la etiqueta no cuadra
    }
}

Conviene además decidir con criterio qué merece este tratamiento. El directorio privado ya está aislado por identificador de usuario y cifrado en reposo por el sistema, de modo que cifrar por encima solo añade valor frente a amenazas concretas: una copia de seguridad que se lleve el fichero fuera del dispositivo, un dispositivo con acceso de administrador, o un requisito de cumplimiento que obligue a ello. Cifrar la caché de imágenes con una clave respaldada por hardware es un gasto de batería disfrazado de rigor.

Cuando una clave válida deja de funcionar

Una clave del Keystore puede volverse permanentemente inutilizable sin que tu código haya hecho nada mal, y el sistema lo comunica con una excepción específica en el momento de inicializar la operación. Ocurre cuando la clave se ató a la autenticación del usuario y este cambia o retira el bloqueo de pantalla, y también cuando se pidió invalidación al registrar nueva biometría y el usuario añade una huella. Ese último caso es deseable, porque impide que una huella recién inscrita por otra persona desbloquee material anterior.

La respuesta correcta nunca es reintentar. Es borrar el alias, generar una clave nueva y aceptar que el dato cifrado con la anterior se ha perdido, lo cual obliga a una decisión de diseño anterior a cualquier línea de código: todo lo que cifres con una clave invalidable tiene que ser reconstruible, ya sea porque el servidor lo puede volver a emitir o porque el usuario lo puede volver a introducir. Cifrar el único ejemplar de algo irremplazable con una clave atada a la biometría es diseñar una pérdida de datos con retardo.

fun descifrarSeguro(bloque: ByteArray): Resultado = try {
    Resultado.Ok(cofre.descifrar(bloque))
} catch (e: KeyPermanentlyInvalidatedException) {
    KeyStore.getInstance("AndroidKeyStore").apply { load(null) }.deleteEntry(ALIAS)
    Resultado.RequiereNuevoRegistro          // reconstruir, jamas reintentar
} catch (e: AEADBadTagException) {
    Resultado.DatoCorrupto                   // integridad rota: no confiar en el
}
💡
La biblioteca de conveniencia ya no es el camino recomendado

Durante años la respuesta estándar a este problema fue EncryptedSharedPreferences del artefacto de seguridad de Jetpack, que envolvía todo lo anterior en dos líneas. Esa biblioteca está descontinuada y no recibirá correcciones, de modo que el código nuevo debe hablar con el Keystore directamente. La noticia es mejor de lo que parece: la envoltura ocultaba precisamente las decisiones que esta lección te pide tomar, y más de un equipo descubrió tarde que estaba cifrando datos irremplazables con una clave que el sistema podía invalidar. Escribir las treinta líneas te obliga a elegir el modo, a gestionar el vector de inicialización y a decidir qué ocurre cuando la clave desaparece, que es exactamente el conocimiento que la conveniencia te estaba ahorrando a crédito.

El Keystore no guarda secretos: reubica la frontera de confianza

La forma más productiva de entender este subsistema es dejar de verlo como almacenamiento y empezar a verlo como un desplazamiento deliberado de la línea que separa lo que un atacante puede alcanzar de lo que no. Antes del Keystore, esa línea coincidía con el perímetro del proceso, y cualquier técnica capaz de leer memoria ganaba la partida entera de una sola vez, porque la clave y el dato cifrado estaban del mismo lado. Después, la línea pasa por dentro del silicio: el dato sigue siendo accesible, el identificador sigue siendo accesible, tu código sigue siendo modificable, y sin embargo el material criptográfico no está en ninguno de esos sitios. Lo que se ha conseguido no es hacer el secreto más difícil de encontrar sino cambiar la clase del problema, sustituyendo un reto de ingeniería inversa, que siempre se acaba resolviendo con tiempo suficiente, por un reto de extracción de hardware, que exige equipamiento físico y acceso prolongado al dispositivo. De aquí se derivan dos corolarios que en la práctica separan el uso competente del uso decorativo. El primero es que la única propiedad que compras es la no exportabilidad del material, y esa propiedad se evapora en el instante en que tu diseño necesita que la clave exista también en otro sitio; en cuanto sincronizas un secreto entre dispositivos o lo respaldas en un servidor, has vuelto al modelo anterior y todo el aparato es ceremonia. El segundo, más sutil y más caro, es que la clave protegida no protege el uso de la clave: un atacante que controle tu proceso no necesita extraer nada, porque puede pedirle al Keystore que descifre en su nombre tantas veces como quiera. Por eso las restricciones declaradas en la generación —el propósito acotado, la exigencia de autenticación reciente, la ventana temporal de validez, la invalidación al cambiar de credencial— no son adornos de configuración sino la mitad sustantiva del mecanismo. Una clave inextraíble con permiso ilimitado de uso es una clave que has escondido perfectamente detrás de una puerta abierta.

⚔️ Construye un cofre local honesto
  1. Genera una clave con propósito acotado y verifica mediante atestación si su nivel de seguridad es de entorno confiable o de elemento seguro.
  2. Implementa cifrado y descifrado autenticados dejando que el proveedor genere el vector de inicialización y almacenándolo junto al texto cifrado.
  3. Intenta reutilizar deliberadamente un vector fijo y documenta por escrito qué garantía se rompe y con qué consecuencia exacta.
  4. Ata una clave a la autenticación del usuario, cambia el bloqueo de pantalla del dispositivo y comprueba qué excepción recibes al usarla.
  5. Clasifica cada dato que cifras como reconstruible o irremplazable y elimina toda dependencia de una clave invalidable en el segundo grupo.