Autentique-me. Se você puder ...


Frequentemente ouço perguntas como "Como implementar a autenticação em um aplicativo Android?", "Onde armazenar um PIN?", "Ei, cara, eu estarei seguro se implementar um recurso de autenticação dessa maneira?" e muito do tipo. Eu estava realmente cansado de responder a essas perguntas, então decidi escrever todos os meus pensamentos sobre isso uma vez para compartilhar com todos os questionadores.


Sumário




Autenticação: Por que eu tenho que fazer isso?


Vamos começar pela definição. A autenticação (do grego: αὐθεντικός authentikos, "real, genuíno", de αὐθέντης authentes, "autor") é o ato de provar uma afirmação, como a identidade de um usuário de sistema de computador.


Portanto, se seu aplicativo tiver informações confidenciais (as informações de qualquer usuário são sensíveis ao IMHO), você deverá adicionar um cenário de autenticação ao aplicativo para impedir o acesso não autorizado a essas informações.


Os cenários de autenticação mais populares são os seguintes:


  • Login + Senha
  • Senha mestra
  • PIN (4 ou mais dígitos)
  • Biometria

Naturalmente, a autenticação de login e senha chega ao seu aplicativo a partir de um back-end e a segurança desse mecanismo é deixada para a equipe de garantia de segurança;) Não se esqueça de implementar a Public Key Pinning .


A autenticação de senha mestra é muito raramente usada e apenas em aplicativos que exigem um alto nível de segurança (por exemplo, gerenciadores de senhas).


Portanto, temos apenas dois cenários mais populares: um PIN e Biometria . Eles são bastante fáceis de usar e relativamente fáceis de implementar (na verdade, não são ...). Neste artigo, abordaremos os principais aspectos da implementação correta desses recursos.



Maneira simples


Imagine que você é um desenvolvedor Android e seu código imprime dinheiro. Você não se preocupa com nada e não precisa de muita experiência em segurança de aplicativos móveis. Mas um dia, um gerente chega até você e dá a tarefa de "Implementar uma autenticação adicional por meio de um PIN e uma impressão digital em nosso aplicativo". A história começa aqui ...


Para implementar a autenticação de PIN, você deve criar duas telas como estas:


E escreva esse código para criar e verificar seu 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 } 

Isso é tudo! Agora, você tem um sistema de autenticação legal por meio de um PIN. Parabéns. Foi tão fácil, não foi?


Claro, você já pegou a ironia em minhas palavras. Dessa forma, é muito ruim porque um PIN é armazenado como texto sem formatação. Se algum malware obtiver acesso ao armazenamento interno do aplicativo, ele receberá o PIN do usuário como está. Você pode me perguntar "Por que isso é tão ruim? É apenas um PIN da autenticação local ...". Sim, mas os usuários tendem a definir o mesmo PIN em qualquer lugar. Portanto, o conhecimento de um PIN de usuário permite que um invasor expanda a superfície de ataque.


Além disso, esse esquema de autenticação não permite implementar a criptografia de dados do usuário com base em um PIN de maneira segura (falaremos sobre isso mais adiante).



Vamos melhorar


Como podemos melhorar nossa implementação anterior? A primeira e óbvia abordagem é pegar um hash do seu PIN e armazená-lo.


Uma função hash é qualquer função que pode ser usada para mapear dados de tamanho arbitrário para valores de tamanho fixo. Os valores retornados por uma função de hash são chamados de valores de hash, códigos de hash, resumos ou simplesmente hashes. Os valores são usados ​​para indexar uma tabela de tamanho fixo chamada tabela de hash. O uso de uma função hash para indexar uma tabela de hash é chamado de endereçamento de armazenamento de hash ou dispersão.

Existem muitas funções de hash disponíveis no Android Framework (na Java Cryptography Architecture , para ser mais preciso), mas hoje nem todas são consideradas seguras. Eu não recomendo usar MD5 e SHA-1 devido a colisões. O SHA-256 é uma boa opção para a maioria das tarefas.


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

Vamos modificar nosso savePin(...) para armazenar o PIN com 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 o hash é um bom começo, mas o hash simples não é suficiente para a nossa tarefa. Na vida real, um invasor já pré-calculou todos os hashes de PIN de 4 dígitos. Ele poderá descriptografar todos os PINs hash roubados com bastante facilidade. Existe uma abordagem para lidar com isso - um sal .


Na criptografia, um salt são dados aleatórios que são usados ​​como uma entrada adicional para uma função unidirecional que "hashes" dados, uma senha ou senha. Os sais são usados ​​para proteger as senhas no armazenamento. Historicamente, uma senha era armazenada em texto sem formatação em um sistema, mas com o tempo foram desenvolvidas salvaguardas adicionais para proteger a senha do usuário contra a leitura do sistema. Um sal é um desses métodos.

Para adicionar um sal ao nosso mecanismo de segurança, precisamos alterar o código mostrado acima de maneira a


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

Observe que você precisa armazenar o sal junto com o PIN, pois é necessário calcular o hash resultante (usando sal) sempre que verificar o PIN a partir da entrada do usuário.


 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 você pode ver, o código ainda não é tão difícil de entender, mas a segurança dessa solução se tornou muito mais forte. Vou dizer ainda mais, essa abordagem está pronta para a produção para a maioria dos aplicativos que não exigem um alto nível de segurança.


"Mas e se eu precisar de uma solução muito mais segura?", Você pergunta. Ok, siga-me.



O caminho certo


Vamos discutir vários pontos de melhoria para nossa abordagem de autenticação.


Em primeiro lugar, a principal falha dos "hashes comuns" (e mesmo dos "hashes comuns salgados") é a velocidade relativamente alta de um ataque de força bruta (cerca de bilhões de hashes por minuto ). Para eliminar essa falha, precisamos usar uma função KDF especial como PBKDF2, que é suportada nativamente pelo Android Framework. Obviamente, há alguma diferença entre as funções do KDF e você provavelmente desejará escolher a outra, mas está fora do escopo deste artigo. Darei a você vários links úteis sobre esse tópico no final do artigo.


Em segundo lugar, não temos criptografia de dados do usuário neste momento. Existem várias maneiras de implementá-lo e mostrarei a mais simples e a mais confiável. Será um conjunto de duas bibliotecas e algum código em torno delas.


Vamos escrever uma chave PBKDF2 criando fábrica para começar.


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

Agora armados com esta fábrica, temos que refatorar nossos savePin() e 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 } 

Assim, acabamos de mitigar a principal falha de nossa solução anterior. É bom, e agora precisamos adicionar a criptografia de dados do usuário. Para implementá-lo, usaremos estas bibliotecas:


  • Sininho - Uma biblioteca de várias plataformas e vários idiomas que fornece APIs criptográficas seguras, fáceis de usar corretamente e difíceis de usar incorretamente.
  • Jetpack Security - Leia e grave arquivos criptografados e preferências compartilhadas seguindo as práticas recomendadas de segurança.

Para obter um bom armazenamento criptografado, precisamos escrever esse 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 ) } ... } 

Só isso. Posteriormente, podemos trabalhar com ele como se fossem SharedPreferences regulares, mas todos os dados serão criptografados. Agora podemos substituir facilmente a implementação 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 } ... } 

Vamos resumir o subtotal. Temos uma chave bastante segura derivada de um PIN e uma abordagem bastante confiável para armazená-lo. Parece legal, mas não o suficiente. E se assumirmos que o invasor teve acesso ao nosso dispositivo e extraiu todos os dados dele. Em teoria, ele tem todos os componentes para descriptografar os dados neste momento. Para resolver esse problema, precisamos realizar duas coisas:


  • um PIN não está armazenado
  • operações de criptografia são baseadas no PIN

Como podemos atingir esses objetivos sem reescrever todo o código? É fácil! Na medida em que usamos o Tink, podemos aplicar seu recurso de criptografia denominado como dados associados.


Dados associados a serem autenticados, mas não criptografados. Os dados associados são opcionais, portanto, este parâmetro pode ser nulo. Nesse caso, o valor nulo é equivalente a uma matriz de bytes vazia (comprimento zero). Para a descriptografia bem-sucedida, os mesmos dados associados devem ser fornecidos junto com o texto cifrado.

É isso aí! Podemos usar um PIN como dados associados para atingir nossas metas designadas. Assim, a possibilidade ou impossibilidade de descriptografar os dados do usuário atuará como um indicador da correção do PIN. Esse esquema geralmente funciona da seguinte maneira:



Se um usuário digitar um PIN incorreto, você receberá GeneralSecurityException ao tentar descriptografar o token de acesso. Portanto, a implementação final pode ser assim:


Mostrar o 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 } } 

Bom resultado! Agora não estamos mais armazenando o PIN e todos os dados são criptografados por padrão. Obviamente, existem várias maneiras de melhorar essa implementação, se você quiser. Acabei de mostrar o princípio básico.



Mas espere, e quanto à biometria?


Eu não acho que "biometria" é sobre segurança. Prefiro chamá-lo de "um recurso de usuário muito conveniente". E é uma guerra santa terrivelmente antiga entre conveniência e segurança. Mas a maioria dos usuários gosta desse tipo de autenticação e nós, como desenvolvedores, precisamos implementá-lo o mais seguro possível.


Infelizmente, a implementação de autenticação biométrica é bastante complicada. É por isso que vou começar mostrando alguns princípios comuns de implementação e dar algumas explicações. Depois disso, vamos nos aprofundar no código.



Este esquema contém uma nuance importante: A chave secreta é salva no disco . Claro que não como um texto simples, mas mesmo assim.


Como você pode ver, criamos uma nova chave de criptografia no keystore e usamos essa chave para criptografar nossa chave secreta derivada de um PIN. Esse esquema nos permite não criptografar novamente todos os dados ao alterar um método de autenticação. Além disso, ainda temos a capacidade de inserir um PIN se a autenticação biométrica falhar por algum motivo. Ok, vamos escrever muito código.


Primeiramente, mostrarei as alterações no fluxo de criação do PIN:


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

Ficaria feliz se o Google incluísse o Tink em Biometria, mas ... Temos que escrever esse código padrão com o Cipher e o KeyStore. Esse código é bastante familiar para as pessoas que trabalham com criptografia no Android, mas quero prestar atenção nos campos de criptografia. Sim, para impedir o ataque do Padding Oracle , não usamos o padding. Assim, atenuamos os riscos ao armazenar a chave secreta no disco.


O código para verificação biométrica é muito semelhante:


Mostrar o 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 atenção ao authenticationCallback.onAuthenticationSucceeded , ele contém a lógica principal da autenticação pós-biométrica. De fato, essa é uma implementação alternativa do método pinIsValid() . Se você não tem um forte entendimento do que está acontecendo nos dois blocos de código anteriores, consulte a documentação oficial biométrica .



Estou completamente protegido?


Fizemos muitas coisas legais para realizar a autenticação com um PIN e biometria, mas é tão confiável e seguro? Obviamente, fizemos o nosso melhor, mas há alguns pontos a serem levados em consideração.


Um PIN clássico possui apenas quatro dígitos e a entropia é muito baixa. Portanto, esse tipo de código não é muito seguro de usar. Apesar de tudo o que fizemos, há uma chance de que um invasor possa decifrar esse código. Sim, ele precisa executar a engenharia reversa do seu aplicativo e entender como você está criptografando os dados do usuário, mas mesmo assim. Se um atacante estiver motivado o suficiente, ele fará isso sem hesitar.


O segundo ponto é sobre smartphones enraizados. Quando se trata de dispositivos raiz, você pode jogar fora todas as suas tentativas de garantia de segurança. Qualquer malware com acesso root é capaz de ignorar todos os mecanismos de segurança. Portanto, você precisa adicionar recursos e verificações extras de segurança ao aplicativo. Sugiro duas coisas simples para atenuar essas falhas:


  • SafetyNet - fornece um conjunto de serviços e APIs que ajudam a proteger seu aplicativo contra ameaças à segurança, incluindo adulteração de dispositivos, URLs incorretos, aplicativos potencialmente perigosos e usuários falsos
  • Ofuscação - lembre-se de que o ProGuard não é uma ferramenta de ofuscação! O ProGuard trata da redução e redução de recursos, não ofuscação ou segurança. Use algo como DexGuard, DexProtector, etc.

O uso do SafetyNet e a ofuscação são um bom próximo passo após a aplicação das abordagens deste artigo. Se você notar imprecisões, falhas de segurança ou outras besteiras, entre em contato. Você pode encontrar todo o código do artigo no GitHub .


E da próxima vez, mostrarei como implementar uma autenticação de PIN usando o back-end. Fique atento.



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


All Articles