Pull to refresh
79.43
red_mad_robot
№1 в разработке цифровых решений для бизнеса

Authenticate me. If you can…

Reading time 14 min
Views 7K


I frequently hear questions like "How to implement authentication in an Android app?", "Where to store a PIN?", "Hey man, will I be secure if I implement an authentication feature in such a way?" and a lot of the kind. I got really tired answering these questions so I decided to write all my thoughts about it once to share with all questioners.


Table of contents




Authentication: Why do I have to do it?


Let's start from the definition. Authentication (from Greek: αὐθεντικός authentikos, "real, genuine", from αὐθέντης authentes, "author") is the act of proving an assertion, such as the identity of a computer system user.


So, if your application has sensitive information (any user's information are sensitive IMHO) you have to add an authentication scenario to the app to prevent unauthorized access to this information.


The most popular authentication scenarios are as follows:


  • Login + Password
  • Master password
  • PIN (4 or more digits)
  • Biometrics

Naturally, login&password authentication comes to your application from a back-end and the security of this mechanism we'll leave to the back-end security assurance team ;) Just don't forget to implement Public Key Pinning.


Master password authentication is very rarely used and only in apps that require a high level of security (e.g. password managers).


Thus, we have only two most popular scenarios: a PIN and Biometrics. They are quite user-friendly and relatively easy in implementation (actually they aren't...). In this article we'll cover the main aspects of the correct implementation of these features.



Simple way


Just imagine, you're an Android developer and your code prints you money. You don't worry about anything, and you ain't got much need in serious mobile apps security expertise. But one day, a manager comes to you and gives a task to "Implement an additional authentication via a PIN and a fingerprint in our application". The story begins here...


To implement PIN authentication you would create a couple of screens like these:
  


And write such code for creating and checking your 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
}

That's all! Now, you have a cool authentication system via a PIN. Congratulations. It was so easy, wasn't it?


Of course, you've already caught the irony in my words. This way is terribly bad because a PIN is stored as plaintext. If malware somehow gets access to the internal application storage, it'll get the user PIN as is. You can ask me "Why is it so bad? It's just a PIN from local authentication...". Yeah, but users tend to set the same PIN everywhere. Therefore, knowledge of a user PIN allows an intruder to expand the attack surface.


Moreover, such authentication scheme doesn't allow you to implement user data encryption based on a PIN in a secure manner (we'll talk about it later).



Let's make it better


How can we improve our previous implementation? The first and evident approach is taking a hash from your PIN and storing this hash.


A hash function is any function that can be used to map data of arbitrary size to fixed-size values. The values returned by a hash function are called hash values, hash codes, digests, or simply hashes. The values are used to index a fixed-size table called a hash table. Use of a hash function to index a hash table is called hashing or scatter storage addressing.

There are lots of available hash functions in Android Framework (in Java Cryptography Architecture, to be precise), but today not each of them is considered secure. I don't recommend using MD5 and SHA-1 due to collisions. SHA-256 is a good choice for most tasks.


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

    return with(digest) {
            update(byteArray)
            digest()
        }
}

Let's modify our savePin(...) method to store the hashed PIN


fun savePin(pin: String) {
    val hashedPin = sha256(pin.toByteArray())
    val encodedHash = Base64.encodeToString(hashedPin, Base64.DEFAULT)

    preferences.edit().putString(StorageKey.PIN, encodedHash).apply()
}

Using hash is a good start, but bare hash is not enough for our task. In the real life an attacker has already pre-computed all the 4-digit PIN hashes. He will be able to decrypt all those stolen hashed PINs quite easily. There is an approach to deal with it — a salt.


In cryptography, a salt is random data that is used as an additional input to a one-way function that "hashes" data, a password or passphrase. Salts are used to safeguard passwords in storage. Historically a password was stored in plaintext on a system, but over time additional safeguards developed to protect a user's password against being read from the system. A salt is one of those methods.

To add a salt to our security mechanism we need to change the code shown above in such a way


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

Note, you have to store the salt together with the PIN because you need to compute resulted hash (using salt) every time when checking the PIN from user input.


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
}

As you can see, the code is still not so hard to understand, but the security of this solution has become much stronger. I'll say even more, this approach is quite production ready for the most applications that don't require a high level of security.


"But what if I need a much more secure solution?", you ask. Ok, follow me.



The right way


Let's discuss several improvement points for our authentication approach.


Firstly, the main flaw of "ordinary hashes" (and even "salted ordinary hashes") is relatively high speed of a brute-force attack (about billions of hashes per minute). To eliminate this flaw we've got to use a special KDF-function like PBKDF2 which is natively supported by the Android Framework. Of course, there is some difference between KDF functions and you'll probably want to choose the other one, but it's out of this article scope. I'll give you several useful links about this topic at the end of the article.


Secondly, we have no user data encryption at this point. There are a lot of ways to implement it and I'll show the simplest and the most reliable one. It'll be a set of two libraries and some code around them.


Let's write a PBKDF2 key creating factory to begin with.


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

Now armed with this factory we've got to refactor our savePin() and pinIsValid() methods


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
}

Thus, we've just mitigated the main flaw of our previous solution. It's good, and now we've got to add user data encryption. To implement it, we'll take these libraries:


  • Tink — A multi-language, cross-platform library that provides cryptographic APIs that are secure, easy to use correctly, and hard(er) to misuse.
  • Jetpack Security — Read and write encrypted files and shared preferences by following security best practices.

To get a good encrypted storage, we've got to write such code:


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

That's all. Later, we can work with it as if it were regular SharedPreferences, but all data will be encrypted. Now we can easily replace the previous implementation.


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

Let's summarize the subtotal. We have quite a secure key derived from a PIN, and a fairly reliable approach to store it. That looks cool, but not enough. What if we assume that the attacker has got access to our device and has extracted the whole data from it. In theory, he has all components to decrypt the data at this moment. To solve this problem, we've got to achieve two things:


  • a PIN isn't stored at all
  • encryption operations are based on the PIN

How can we achieve these goals without rewriting the whole code? It's easy! Insofar as we're using Tink, we can apply its encryption feature named as associated data.


Associated data to be authenticated, but not encrypted. Associated data is optional, so this parameter can be null. In this case the null value is equivalent to an empty (zero-length) byte array. For successful decryption the same associatedData must be provided along with the ciphertext.

That's it! We can use a PIN as associated data to achieve our designated goals. Thus, possibility or impossibility to decrypt the user data will act as an indicator of the PIN correctness. This scheme usually works as follows:



If a user enters an incorrect PIN, you'll receive GeneralSecurityException when trying to decrypt the access token. So, the final implementation might look like this:


Show the code
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
 }
}    

Nice result! Now we are not storing the PIN anymore, and all data is encrypted by default. Of course, there are a lot of ways to improve this implementation if you want to. I've just shown the basic principle.



But wait, what about biometrics?


I don't think that "biometrics" is about security. I'd rather name it "a very convenient user feature". And it's a terribly old holy war between convenience and security. But most users like this kind of authentication and we as developers have to implement it as secure as possible.


Unfortunately, biometric auth implementation is quite tricky. That's why I'll start with showing you some common implementation principle and give some explanations. After this we'll dive deep into the code.



This scheme contains one important nuance: The secret key is saved on the disk. Of course not as a plain text, but nonetheless.


As you can see, we have created a new encryption key in the keystore and we use this key to encrypt our secret key that is derived from a PIN. Such a scheme allows us not to re-encrypt all data when changing an authentication method. Moreover, we still have the ability to enter a PIN if biometric authentication had failed for any reasons. Ok, let's write a lot of code.


Firstly, I'll show the changes in the PIN creation flow:


Show the code
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)
   }
 }
}

I would be glad if Google included Tink in Biometrics, but… We have to write this boilerplate code with Cipher and KeyStore. This code is quite familiar to those people who work with cryptography in Android, but I want to pay your attention to encryption paddings. Yes, to prevent Padding Oracle attack we don't use padding at all. Thus, we mitigate risks when storing the secret key on the disk.


The code for biometric checking is very similar:


Show the code
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))
  }
 }
}

Pay your attention to the authenticationCallback.onAuthenticationSucceeded, it contains the key logic of post-biometric authentication. In fact, this is an alternative implementation of the pinIsValid() method. If you have no strong understanding of what's happening in two previous code blocks, please refer to the biometric official documentation.



Am I completely protected?


We've done a lot of cool things to realize authentication with a PIN and biometrics, but is it so reliable and secure? Of course, we've done our best, but there is a couple of points to take into account.


A classic PIN has only four digits and the entropy of it is too low. So, such kind of code isn't quite secure to use. Despite everything we've done, there is a chance that an intruder can crack this code. Yeah, he has to fulfil the reverse engineering of your application and understand how you're encrypting user's data, but nonetheless. If an attacker is motivated enough he'll do it without hesitation.


The second point is about rooted smartphones. When it comes to rooted devices, you can throw away all your security assurance attempts. Any malware with root access is able to bypass all security mechanisms. Therefore, you have to add extra security features and checks to the application. I suggest you two most simple things to mitigate these flaws:


  • SafetyNet — it provides a set of services and APIs that help protect your app against security threats, including device tampering, bad URLs, potentially harmful apps, and fake users
  • Obfuscationplease remember that ProGuard is not an obfuscation tool! ProGuard is about minifying and resource shrinking, not obfuscation or security. Use something like DexGuard, DexProtector, etc.

Usage of SafetyNet and obfuscation are a good next step after applying approaches from this article. If you note inaccuracies, security flaws or other bullshit, please let me know. You can find all the code from the article on GitHub.


And next time I'll show you how to implement a PIN authentication using back-end. Stay tuned.



Tags:
Hubs:
+7
Comments 0
Comments Leave a comment

Articles

Information

Website
redmadrobot.ru
Registered
Founded
Employees
1,001–5,000 employees
Location
Россия