Autenticame Si puedes ...


Frecuentemente escucho preguntas como "¿Cómo implementar la autenticación en una aplicación de Android?", "¿Dónde almacenar un PIN?", "Oye, ¿estaré seguro si implemento una función de autenticación de esa manera?" y mucho por el estilo. Me cansé realmente de responder estas preguntas, así que decidí escribir todos mis pensamientos al respecto una vez para compartirlos con todos los interrogadores.


Tabla de contenidos




Autenticación: ¿Por qué tengo que hacerlo?


Comencemos por la definición. La autenticación (del griego: αὐθεντικός authentikos, "real, genuino", de αὐθέντης Autores, "autor") es el acto de probar una afirmación, como la identidad de un usuario del sistema informático.


Entonces, si su aplicación tiene información confidencial (la información de cualquier usuario es confidencial en mi humilde opinión), debe agregar un escenario de autenticación a la aplicación para evitar el acceso no autorizado a esta información.


Los escenarios de autenticación más populares son los siguientes:


  • Login + Contraseña
  • Contraseña maestra
  • PIN (4 o más dígitos)
  • Biometría

Naturalmente, la autenticación de inicio de sesión y contraseña llega a su aplicación desde un back-end y la seguridad de este mecanismo la dejaremos al equipo de garantía de seguridad de back-end;) Simplemente no se olvide de implementar Public Key Pinning .


La autenticación de contraseña maestra se usa muy raramente y solo en aplicaciones que requieren un alto nivel de seguridad (por ejemplo, administradores de contraseñas).


Por lo tanto, solo tenemos dos escenarios más populares: un PIN y biometría . Son bastante fáciles de usar y relativamente fáciles de implementar (en realidad no lo son ...). En este artículo cubriremos los aspectos principales de la implementación correcta de estas características.



Manera simple


Imagínese, usted es un desarrollador de Android y su código le imprime dinero. No te preocupes por nada, y no tienes mucha necesidad de tener experiencia en seguridad de aplicaciones móviles serias. Pero un día, un gerente viene a usted y le da la tarea de "Implementar una autenticación adicional a través de un PIN y una huella digital en nuestra aplicación". La historia comienza aquí ...


Para implementar la autenticación PIN, crearía un par de pantallas como estas:


Y escriba dicho código para crear y verificar su PIN


fun savePin(pin: String) { preferences.edit().putString(StorageKey.PIN, pin).apply() } 

 fun authenticate(pin: String) { authenticationState.value = if (pinIsValid(pin)) { AuthenticationState.AUTHENTICATED } else { AuthenticationState.INVALID_AUTHENTICATION } } private fun pinIsValid(pin: String): Boolean { return preferences.getString(StorageKey.PIN, null) == pin } 

Eso es todo! Ahora, tiene un sistema de autenticación genial a través de un PIN. Felicidades Fue tan fácil, ¿no?


Por supuesto, ya has captado la ironía en mis palabras. De esta manera es terriblemente malo porque un PIN se almacena como texto sin formato. Si el malware de alguna manera obtiene acceso al almacenamiento interno de la aplicación, obtendrá el PIN del usuario tal como está. Puedes preguntarme "¿Por qué es tan malo? Es solo un PIN de autenticación local ...". Sí, pero los usuarios tienden a establecer el mismo PIN en todas partes. Por lo tanto, el conocimiento de un PIN de usuario permite que un intruso expanda la superficie de ataque.


Además, dicho esquema de autenticación no le permite implementar el cifrado de datos de usuario basado en un PIN de manera segura (hablaremos de ello más adelante).



Hagámoslo mejor


¿Cómo podemos mejorar nuestra implementación anterior? El primer y obvio enfoque es tomar un hash de su PIN y almacenar este hash.


Una función hash es cualquier función que se puede utilizar para asignar datos de tamaño arbitrario a valores de tamaño fijo. Los valores devueltos por una función hash se denominan valores hash, códigos hash, resúmenes o simplemente hash. Los valores se usan para indexar una tabla de tamaño fijo llamada tabla hash. El uso de una función hash para indexar una tabla hash se denomina hashing o direccionamiento de almacenamiento de dispersión.

Hay muchas funciones hash disponibles en Android Framework (en Java Cryptography Architecture , para ser precisos), pero hoy en día no todas se consideran seguras. No recomiendo usar MD5 y SHA-1 debido a colisiones. SHA-256 es una buena opción para la mayoría de las tareas.


 fun sha256(byteArray: ByteArray): ByteArray { val digest = try { MessageDigest.getInstance("SHA-256") } catch (e: NoSuchAlgorithmException) { MessageDigest.getInstance("SHA") } return with(digest) { update(byteArray) digest() } } 

Modifiquemos nuestro savePin(...) para almacenar el PIN hash


 fun savePin(pin: String) { val hashedPin = sha256(pin.toByteArray()) val encodedHash = Base64.encodeToString(hashedPin, Base64.DEFAULT) preferences.edit().putString(StorageKey.PIN, encodedHash).apply() } 

Usar hash es un buen comienzo, pero el hash simple no es suficiente para nuestra tarea. En la vida real, un atacante ya ha calculado previamente todos los hashes de PIN de 4 dígitos. Podrá descifrar todos los PIN hash robados con bastante facilidad. Hay un enfoque para tratarlo: una sal .


En criptografía, una sal son datos aleatorios que se utilizan como entrada adicional a una función unidireccional que "codifica" datos, una contraseña o frase de contraseña. Las sales se utilizan para proteger las contraseñas en el almacenamiento. Históricamente, una contraseña se almacenaba en texto sin formato en un sistema, pero con el tiempo se desarrollaron salvaguardas adicionales para proteger la contraseña de un usuario contra la lectura del sistema. Una sal es uno de esos métodos.

Para agregar sal a nuestro mecanismo de seguridad, necesitamos cambiar el código que se muestra arriba de tal manera


 fun generate(lengthByte: Int = 32): ByteArray { val random = SecureRandom() val salt = ByteArray(lengthByte) random.nextBytes(salt) return salt } 

 fun savePin(pin: String) { val salt = Salt.generate() val saltedPin = pin.toByteArray() + salt val hashedPin = Sha256.hash(saltedPin) val encodedHash = Base64.encodeToString(hashedPin, Base64.DEFAULT) val encodedSalt = Base64.encodeToString(salt, Base64.DEFAULT) preferences.edit() .putString(StorageKey.PIN, encodedHash) .putString(StorageKey.SALT, encodedSalt) .apply() } 

Tenga en cuenta que debe almacenar la sal junto con el PIN porque necesita calcular el hash resultante (usando sal) cada vez que verifica el PIN desde la entrada del usuario.


 private fun pinIsValid(pin: String): Boolean { val encodedSalt = preferences.getString(StorageKey.SALT, null) val encodedHashedPin = preferences.getString(StorageKey.PIN, null) val salt = Base64.decode(encodedSalt, Base64.DEFAULT) val storedHashedPin = Base64.decode(encodedHashedPin, Base64.DEFAULT) val enteredHashedPin = Sha256.hash(pin.toByteArray() + salt) return storedHashedPin contentEquals enteredHashedPin } 

Como puede ver, el código aún no es tan difícil de entender, pero la seguridad de esta solución se ha fortalecido mucho. Diré aún más, este enfoque está listo para la producción para la mayoría de las aplicaciones que no requieren un alto nivel de seguridad.


"¿Pero qué pasa si necesito una solución mucho más segura?", Preguntas. Ok, sígueme



La manera correcta


Analicemos varios puntos de mejora para nuestro enfoque de autenticación.


En primer lugar, la falla principal de los "hashes comunes" (e incluso los "hashes comunes salados") es la velocidad relativamente alta de un ataque de fuerza bruta (alrededor de miles de millones de hashes por minuto ). Para eliminar esta falla, debemos utilizar una función KDF especial como PBKDF2, que es compatible de forma nativa con Android Framework. Por supuesto, hay alguna diferencia entre las funciones de KDF y probablemente desee elegir la otra, pero está fuera del alcance de este artículo. Le daré varios enlaces útiles sobre este tema al final del artículo.


En segundo lugar, no tenemos cifrado de datos de usuario en este momento. Hay muchas formas de implementarlo y mostraré la más simple y la más confiable. Será un conjunto de dos bibliotecas y un código a su alrededor.


Para empezar, escribamos una clave PBKDF2 que crea la fábrica.


 object Pbkdf2Factory { private const val DEFAULT_ITERATIONS = 10000 private const val DEFAULT_KEY_LENGTH = 256 private val secretKeyFactory by lazy { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { SecretKeyFactory.getInstance("PBKDF2withHmacSHA1") } else { SecretKeyFactory.getInstance("PBKDF2withHmacSHA256") } } fun createKey( passphraseOrPin: CharArray, salt: ByteArray, iterations: Int = DEFAULT_ITERATIONS, outputKeyLength: Int = DEFAULT_KEY_LENGTH ): SecretKey { val keySpec = PBEKeySpec(passphraseOrPin, salt, iterations, outputKeyLength) return secretKeyFactory.generateSecret(keySpec) } } 

Ahora armados con esta fábrica, tenemos que refactorizar nuestros savePin() y pinIsValid()


 fun savePin(pin: String) { val salt = Salt.generate() val secretKey = Pbkdf2Factory.createKey(pin.toCharArray(), salt) val encodedKey = Base64.encodeToString(secretKey.encoded, Base64.DEFAULT) val encodedSalt = Base64.encodeToString(salt, Base64.DEFAULT) preferences.edit() .putString(StorageKey.KEY, encodedKey) .putString(StorageKey.SALT, encodedSalt) .apply() pinIsCreated.value = true } 

 private fun pinIsValid(pin: String): Boolean { val encodedSalt = preferences.getString(StorageKey.SALT, null) val encodedKey = preferences.getString(StorageKey.KEY, null) val salt = Base64.decode(encodedSalt, Base64.DEFAULT) val storedKey = Base64.decode(encodedKey, Base64.DEFAULT) val enteredKey = Pbkdf2Factory.createKey(pin.toCharArray(), salt) return storedKey contentEquals enteredKey.encoded } 

Por lo tanto, acabamos de mitigar la falla principal de nuestra solución anterior. Está bien, y ahora tenemos que agregar el cifrado de datos del usuario. Para implementarlo, tomaremos estas bibliotecas:


  • Tink : una biblioteca multiplataforma multilingüe que proporciona API criptográficas que son seguras, fáciles de usar correctamente y difíciles de usar.
  • Jetpack Security : lea y escriba archivos cifrados y preferencias compartidas siguiendo las mejores prácticas de seguridad.

Para obtener un buen almacenamiento encriptado, tenemos que escribir dicho código:


 class App : Application() { ... val encryptedStorage by lazy { EncryptedSharedPreferences.create( "main_storage", "main_storage_key", this, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM ) } ... } 

Eso es todo Más tarde, podemos trabajar con él como si fuera SharedPreferences normales, pero todos los datos se SharedPreferences . Ahora podemos reemplazar fácilmente la implementación anterior.


 class CreatePinViewModel(application: Application) : AndroidViewModel(application) { ... private val preferences by lazy { getApplication<App>().encryptedStorage } ... } 

 class InputPinViewModel(application: Application) : AndroidViewModel(application) { ... private val preferences by lazy { getApplication<App>().encryptedStorage } ... } 

Resumamos el subtotal. Tenemos una clave bastante segura derivada de un PIN, y un enfoque bastante confiable para almacenarla. Eso se ve bien, pero no lo suficiente. ¿Qué pasa si suponemos que el atacante tiene acceso a nuestro dispositivo y ha extraído todos los datos de él? En teoría, tiene todos los componentes para descifrar los datos en este momento. Para resolver este problema, debemos lograr dos cosas:


  • no se almacena un PIN
  • las operaciones de cifrado se basan en el PIN

¿Cómo podemos lograr estos objetivos sin reescribir todo el código? Es facil! En la medida en que usemos Tink, podemos aplicar su función de cifrado denominada datos asociados.


Datos asociados que se autenticarán, pero no se cifrarán. Los datos asociados son opcionales, por lo que este parámetro puede ser nulo. En este caso, el valor nulo es equivalente a una matriz de bytes vacía (longitud cero). Para un descifrado exitoso, se deben proporcionar los mismos datos asociados junto con el texto cifrado.

Eso es todo! Podemos usar un PIN como datos asociados para lograr nuestros objetivos designados. Por lo tanto, la posibilidad o imposibilidad de descifrar los datos del usuario actuará como un indicador de la corrección del PIN. Este esquema generalmente funciona de la siguiente manera:



Si un usuario ingresa un PIN incorrecto, recibirá GeneralSecurityException cuando intente descifrar el token de acceso. Entonces, la implementación final podría verse así:


Muestra el código
 class CreatePinViewModel(application: Application): AndroidViewModel(application) { ... private val fakeAccessToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzZWNyZXQiOiJXZSdyZSBoaXJpbmcgOykifQ.WZrEWG-l3VsJzJrbnjn2BIYO68gHIGyat6jrw7Iu-Rw" private val preferences by lazy { getApplication<App>().encryptedStorage } private val aead by lazy { getApplication<App>().pinSecuredAead } ... fun savePin(pin: String) { val salt = Salt.generate() val secretKey = Pbkdf2Factory.createKey(pin.toCharArray(), salt) val encryptedToken = aead.encrypt( fakeAccessToken.toByteArray(), secretKey.encoded ) preferences.edit { putString(StorageKey.TOKEN, Base64.encodeToString( encryptedToken, Base64.DEFAULT )) putString(StorageKey.SALT, Base64.encodeToString(salt, Base64.DEFAULT)) putBoolean(StorageKey.PIN_IS_ENABLED, true) } ... } } 

 class InputPinViewModel(application: Application) : AndroidViewModel(application) { ... private val preferences by lazy { getApplication<App>().encryptedStorage } private val aead by lazy { getApplication<App>().pinSecuredAead } fun authenticate(pin: String) { authenticationState.value = if (pinIsValid(pin)) { AuthenticationState.AUTHENTICATED } else { AuthenticationState.INVALID_AUTHENTICATION } } private fun pinIsValid(pin: String): Boolean { val salt = Base64.decode( preferences.getString(StorageKey.SALT, null), Base64.DEFAULT ) val secretKey = Pbkdf2Factory.createKey(pin.toCharArray(), salt) val token = try { val encryptedToken = Base64.decode( preferences.getString(StorageKey.TOKEN, null), Base64.DEFAULT ) aead.decrypt(encryptedToken, secretKey.encoded) } catch (e: GeneralSecurityException) { null } return token?.isNotEmpty() ?: false } } 

Buen resultado! Ahora ya no estamos almacenando el PIN, y todos los datos están encriptados de manera predeterminada. Por supuesto, hay muchas maneras de mejorar esta implementación si así lo desea. Acabo de mostrar el principio básico.



Pero espera, ¿qué pasa con la biometría?


No creo que "biometría" se trate de seguridad. Prefiero llamarlo "una función de usuario muy conveniente". Y es una guerra santa terriblemente antigua entre conveniencia y seguridad. Pero a la mayoría de los usuarios les gusta este tipo de autenticación y nosotros, como desarrolladores, tenemos que implementarla de la manera más segura posible.


Desafortunadamente, la implementación de autenticación biométrica es bastante complicada. Es por eso que comenzaré por mostrarle un principio de implementación común y dar algunas explicaciones. Después de esto, profundizaremos en el código.



Este esquema contiene un matiz importante: la clave secreta se guarda en el disco . Por supuesto, no como un texto simple, pero no obstante.


Como puede ver, hemos creado una nueva clave de cifrado en el almacén de claves y la utilizamos para cifrar nuestra clave secreta que se deriva de un PIN. Tal esquema nos permite no volver a cifrar todos los datos al cambiar un método de autenticación. Además, todavía tenemos la capacidad de ingresar un PIN si la autenticación biométrica falló por algún motivo. Ok, escribamos mucho código.


En primer lugar, mostraré los cambios en el flujo de creación de PIN:


Muestra el código
 class CreatePinViewModel(application: Application): AndroidViewModel(application) { companion object { private const val ANDROID_KEY_STORE = "AndroidKeyStore" private const val KEY_NAME = "biometric_key" } ... val biometricEnableDialog = MutableLiveData<SingleLiveEvent<Unit>>() val biometricParams = MutableLiveData<BiometricParams>() val authenticationCallback = object : BiometricPrompt.AuthenticationCallback() { override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { super.onAuthenticationError(errorCode, errString) } override fun onAuthenticationSucceeded(result: AuthenticationResult) { super.onAuthenticationSucceeded(result) val encryptedSecretKey = result.cryptoObject?.cipher?.doFinal( secretKey.encoded ) preferences.edit { putString(StorageKey.KEY, Base64.encodeToString( encryptedSecretKey, Base64.DEFAULT )) } pinIsCreated.postValue(true) } override fun onAuthenticationFailed() { super.onAuthenticationFailed() } } ... private val biometricManager by lazy { getApplication<App>().biometricManager } private val keyStore = KeyStore.getInstance(ANDROID_KEY_STORE) private lateinit var secretKey: SecretKey ... fun enableBiometric(isEnabled: Boolean) { generateKey() val cipher = createCipher().also { preferences.edit { putString(StorageKey.KEY_IV, Base64.encodeToString(it.iv, Base64.DEFAULT)) } } val promptInfo = createPromptInfo() val cryptoObject = BiometricPrompt.CryptoObject(cipher) if (isEnabled) { biometricParams.value = BiometricParams(isEnabled, promptInfo, cryptoObject) } else { pinIsCreated.value = true } } private fun createPromptInfo(): BiometricPrompt.PromptInfo { return BiometricPrompt.PromptInfo.Builder() .setTitle("Create biometric authorization") .setSubtitle("Touch your biometric sensor") .setNegativeButtonText("Cancel") .build() } private fun generateKey() { try { keyStore.load(null) val keyProperties = PURPOSE_ENCRYPT or PURPOSE_DECRYPT val builder = KeyGenParameterSpec.Builder(KEY_NAME, keyProperties) .setBlockModes(BLOCK_MODE_CBC) .setUserAuthenticationRequired(true) .setEncryptionPaddings(ENCRYPTION_PADDING_NONE) val keyGenerator = KeyGenerator.getInstance( KEY_ALGORITHM_AES, ANDROID_KEY_STORE ) keyGenerator.run { init(builder.build()) generateKey() } } catch (e: Exception) { authenticationCallback.onAuthenticationError( BiometricConstants.ERROR_NO_DEVICE_CREDENTIAL, e.localizedMessage ) } } private fun createCipher(): Cipher { val key = with(keyStore) { load(null) getKey(KEY_NAME, null) } return Cipher.getInstance( "$KEY_ALGORITHM_AES/$BLOCK_MODE_CBC/$ENCRYPTION_PADDING_NONE" ).apply { init(Cipher.ENCRYPT_MODE, key) } } } 

Me gustaría que Google incluyera a Tink en Biometrics, pero ... Tenemos que escribir este código repetitivo con Cipher y KeyStore. Este código es bastante familiar para aquellas personas que trabajan con criptografía en Android, pero quiero prestarle atención a los rellenos de cifrado. Sí, para evitar el ataque del Relleno Oracle no utilizamos relleno en absoluto. Por lo tanto, mitigamos los riesgos al almacenar la clave secreta en el disco.


El código para la verificación biométrica es muy similar:


Muestra el código
 class InputPinViewModel(application: Application) : AndroidViewModel(application) { companion object { private const val ANDROID_KEY_STORE = "AndroidKeyStore" private const val KEY_NAME = "biometric_key" } ... val biometricErrorMessage = MutableLiveData<SingleLiveEvent<String>>() val biometricParams = MutableLiveData<BiometricParams>() ... private val biometricManager by lazy { getApplication<App>().biometricManager } private val keyStore = KeyStore.getInstance(ANDROID_KEY_STORE) val authenticationCallback = object : BiometricPrompt.AuthenticationCallback() { override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { super.onAuthenticationError(errorCode, errString) } override fun onAuthenticationSucceeded(result: AuthenticationResult) { super.onAuthenticationSucceeded(result) val encryptedSecretKey = Base64.decode( preferences.getString(StorageKey.KEY, ""), Base64.DEFAULT ) val secretKey = result.cryptoObject?.cipher?.doFinal(encryptedSecretKey) val token = try { val encryptedToken = Base64.decode( preferences.getString(StorageKey.TOKEN, null), Base64.DEFAULT ) aead.decrypt(encryptedToken, secretKey) } catch (e: GeneralSecurityException) { null } val state = if (token?.isNotEmpty() == true) { AuthenticationState.AUTHENTICATED } else { AuthenticationState.INVALID_AUTHENTICATION } authenticationState.postValue(state) } override fun onAuthenticationFailed() { super.onAuthenticationFailed() } } ... fun biometricAuthenticate() { if (preferences.contains(StorageKey.KEY)) { when (biometricManager.canAuthenticate()) { BiometricManager.BIOMETRIC_SUCCESS -> { val promptInfo = createPromptInfo() val cryptoObject = BiometricPrompt.CryptoObject(createCipher()) biometricParams.value = BiometricParams(promptInfo, cryptoObject) } } } else { biometricErrorMessage.value = SingleLiveEvent( "Biometric authentication isn't configured" ) } } ... private fun createPromptInfo(): BiometricPrompt.PromptInfo { return BiometricPrompt.PromptInfo.Builder() .setTitle("Biometric login for my app") .setSubtitle("Log in using your biometric credential") .setNegativeButtonText("Cancel") .build() } private fun createCipher(): Cipher { val key = with(keyStore) { load(null) getKey(KEY_NAME, null) } return Cipher.getInstance( "$KEY_ALGORITHM_AES/$BLOCK_MODE_CBC/$ENCRYPTION_PADDING_NONE" ).apply { val iv = Base64.decode( preferences.getString(StorageKey.KEY_IV, null), Base64.DEFAULT ) init(Cipher.DECRYPT_MODE, key, IvParameterSpec(iv)) } } } 

Preste atención a la authenticationCallback.onAuthenticationSucceeded , que contiene la lógica clave de la autenticación post-biométrica. De hecho, esta es una implementación alternativa del método pinIsValid() . Si no comprende bien lo que está sucediendo en dos bloques de código anteriores, consulte la documentación oficial biométrica .



¿Estoy completamente protegido?


Hemos hecho muchas cosas interesantes para realizar la autenticación con un PIN y datos biométricos, pero ¿es tan confiable y seguro? Por supuesto, hemos hecho todo lo posible, pero hay un par de puntos a tener en cuenta.


Un PIN clásico tiene solo cuatro dígitos y la entropía es demasiado baja. Entonces, este tipo de código no es seguro de usar. A pesar de todo lo que hemos hecho, existe la posibilidad de que un intruso pueda descifrar este código. Sí, tiene que cumplir con la ingeniería inversa de su aplicación y comprender cómo está encriptando los datos del usuario, pero de todos modos. Si un atacante está lo suficientemente motivado, lo hará sin dudarlo.


El segundo punto es sobre los teléfonos inteligentes rooteados. Cuando se trata de dispositivos rooteados, puede descartar todos sus intentos de garantía de seguridad. Cualquier malware con acceso raíz puede eludir todos los mecanismos de seguridad. Por lo tanto, debe agregar funciones de seguridad y controles adicionales a la aplicación. Te sugiero dos cosas más simples para mitigar estos defectos:


  • SafetyNet : proporciona un conjunto de servicios y API que ayudan a proteger su aplicación contra amenazas de seguridad, incluida la manipulación de dispositivos, URL defectuosas, aplicaciones potencialmente dañinas y usuarios falsos
  • Ofuscación : recuerde que ProGuard no es una herramienta de ofuscación. ProGuard se trata de minimizar y reducir los recursos, no de ofuscación o seguridad. Use algo como DexGuard, DexProtector, etc.

El uso de SafetyNet y la ofuscación son un buen próximo paso después de aplicar los enfoques de este artículo. Si observa imprecisiones, fallas de seguridad u otras tonterías, hágamelo saber. Puede encontrar todo el código del artículo en GitHub .


Y la próxima vez le mostraré cómo implementar una autenticación PIN mediante el back-end. Estén atentos.



Source: https://habr.com/ru/post/475112/


All Articles