
我经常听到诸如“如何在Android应用程序中实现身份验证?”,“在哪里存储PIN?”,“嘿,如果我以这种方式实现身份验证功能,我会安全吗?”之类的问题。 还有很多。 我真的很疲倦地回答这些问题,所以我决定就此写下所有想法,与所有提问者分享。
目录
身份验证:为什么必须这样做?
让我们从定义开始。 身份验证 (来自希腊语:“真实”,来自“真实”,来自“ Authentes”,“作者”)是证明诸如计算机系统用户身份之类的断言的动作。
因此,如果您的应用程序具有敏感信息(任何用户的信息都是敏感的恕我直言),则必须向该应用程序添加身份验证方案,以防止未经授权访问此信息。
最受欢迎的身份验证方案如下:
- 登录名和密码
- 主密码
- PIN(4位或更多数字)
- 生物识别
当然, 登录名和密码身份验证是从后端进入您的应用程序的,这种机制的安全性将留给后端安全保证团队;)别忘了实现公钥固定 。
主密码身份验证很少使用,仅在需要高度安全性的应用程序(例如密码管理器)中使用。
因此,我们只有两种最流行的方案: PIN和Biometrics 。 它们非常人性化并且易于实施(实际上不是。)。 在本文中,我们将介绍这些功能的正确实现的主要方面。
简单的方法
试想一下,您是一名Android开发人员,而您的代码可以印钱。 您无需担心任何事情,也不需要过多的认真的移动应用程序安全性专业知识。 但是有一天,一位经理来找您,任务是“在我们的应用程序中通过PIN和指纹实施额外的身份验证”。 故事从这里开始...
要实现PIN身份验证,您将创建几个屏幕,如下所示:


并编写用于创建和检查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 }
仅此而已! 现在,您已经可以通过PIN使用很酷的身份验证系统。 恭喜你 这很容易,不是吗?
当然,用我的话你已经引起了讽刺。 这种方式非常糟糕,因为PIN以明文形式存储。 如果恶意软件以某种方式可以访问内部应用程序存储,它将按原样获取用户PIN。 您可以问我“为什么这么糟?这只是本地身份验证的PIN码...”。 是的,但是用户倾向于在各处设置相同的PIN。 因此,了解用户PIN可以使入侵者扩大攻击面。
而且,这种身份验证方案不允许您以安全的方式基于PIN来实现用户数据加密(我们将在后面讨论)。
让我们变得更好
我们如何改善以前的实施方式? 第一种也是显而易见的方法是从PIN中获取哈希值并存储该哈希值。
散列函数是可用于将任意大小的数据映射到固定大小的值的任何函数。 哈希函数返回的值称为哈希值,哈希码,摘要或哈希。 这些值用于索引称为哈希表的固定大小的表。 使用哈希函数索引哈希表的过程称为哈希或分散存储寻址。
Android框架中有很多可用的哈希函数(准确地说,是Java密码体系结构 ),但是今天并不是每个哈希函数都被认为是安全的。 由于冲突,我不建议使用MD5和SHA-1 。 SHA-256是大多数任务的不错选择。
fun sha256(byteArray: ByteArray): ByteArray { val digest = try { MessageDigest.getInstance("SHA-256") } catch (e: NoSuchAlgorithmException) { MessageDigest.getInstance("SHA") } return with(digest) { update(byteArray) digest() } }
让我们修改savePin(...)
方法以存储散列PIN
fun savePin(pin: String) { val hashedPin = sha256(pin.toByteArray()) val encodedHash = Base64.encodeToString(hashedPin, Base64.DEFAULT) preferences.edit().putString(StorageKey.PIN, encodedHash).apply() }
使用哈希是一个好的开始,但是光是哈希不足以完成我们的任务。 在现实生活中,攻击者已经预先计算了所有4位PIN哈希。 他将能够很容易地解密所有那些被盗的散列PIN。 有一种应对方法- 盐 。
在密码术中, 盐是随机数据,用作“散列”数据,密码或密码短语的单向函数的附加输入。 盐用于保护存储中的密码。 过去,密码以明文形式存储在系统中,但是随着时间的流逝,开发了其他保护措施来保护用户的密码,以防系统读取用户密码。 盐是那些方法之一。
为了给我们的安全机制加盐,我们需要以这种方式更改上面显示的代码
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() }
请注意,您必须将盐与PIN一起存储,因为每次从用户输入中检查PIN时都需要计算结果哈希(使用盐)。
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 }
如您所见,代码仍然不难理解,但是此解决方案的安全性变得更加强大。 我还要说的是,这种方法已经为大多数不需要高安全性的应用程序准备就绪。
“但是,如果我需要一个更安全的解决方案怎么办?”,您问。 好,跟我来
正确的方法
让我们讨论我们的身份验证方法的几个改进点。
首先,“普通哈希”(甚至“盐渍普通哈希”)的主要缺陷是蛮力攻击的速度相对较高( 每分钟约数十亿个哈希 )。 为了消除此缺陷,我们必须使用Android框架本身支持的特殊KDF函数(例如PBKDF2) 。 当然,KDF函数之间有一些区别,您可能要选择另一个,但这不在本文的讨论范围之内。 在文章结尾,我将为您提供有关此主题的一些有用链接。
其次,我们目前还没有用户数据加密。 有很多方法可以实现它,我将展示最简单和最可靠的方法。 这将是两个库的集合,周围还有一些代码。
首先,让我们编写一个PBKDF2密钥创建工厂。
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) } }
现在有了这个工厂,我们必须重构我们的savePin()
和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 }
因此,我们只是缓解了先前解决方案的主要缺陷。 很好,现在我们必须添加用户数据加密。 为了实现它,我们将使用以下库:
- Tink-一种多语言,跨平台的库,提供安全,易于正确使用且难以滥用的加密API。
- Jetpack安全性 -遵循安全性最佳实践来读写加密文件和共享首选项。
为了获得良好的加密存储,我们必须编写以下代码:
class App : Application() { ... val encryptedStorage by lazy { EncryptedSharedPreferences.create( "main_storage", "main_storage_key", this, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM ) } ... }
仅此而已。 以后,我们可以像常规的SharedPreferences
一样使用它,但是所有数据都会被加密。 现在,我们可以轻松替换以前的实现。
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 } ... }
让我们总结一下小计。 我们有从PIN派生的相当安全的密钥,以及存储它的相当可靠的方法。 看起来很酷,但还不够。 如果我们假设攻击者可以访问我们的设备并从中提取全部数据怎么办。 从理论上讲,他此时具有解密数据的所有组件。 为了解决这个问题,我们必须实现两件事:
我们如何在不重写整个代码的情况下实现这些目标? 很简单! 在使用Tink的范围内,我们可以应用其加密功能(称为关联数据)。
关联的数据需要认证,但不加密。 关联数据是可选的,因此此参数可以为null。 在这种情况下,空值等效于一个空(零长度)字节数组。 为了成功解密,必须与密文一起提供相同的associatedData。
就是这样! 我们可以使用PIN作为关联数据来实现我们指定的目标。 因此,解密用户数据的可能性或不可能性将作为PIN正确性的指标。 该方案通常如下工作:

如果用户输入了错误的PIN,则在尝试解密访问令牌时会收到GeneralSecurityException 。 因此,最终的实现可能如下所示:
显示代码 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 } }
好结果! 现在我们不再存储PIN,并且默认情况下所有数据都是加密的。 当然,如果需要,有很多方法可以改善此实现。 我刚刚展示了基本原理。
但是等等,生物识别技术呢?
我认为“生物计量学”与安全性无关。 我宁愿将其命名为“一种非常方便的用户功能”。 这是便利与安全之间的一场古老的圣战。 但是大多数用户都喜欢这种身份验证,我们作为开发人员必须尽可能安全地实施它。
不幸的是,生物特征认证实现非常棘手。 这就是为什么我将向您展示一些常见的实现原理并给出一些解释。 之后,我们将深入研究代码。

该方案包含一个重要的细微差别: 密钥保存在磁盘上 。 当然,不是纯文本,而是。
如您所见,我们在密钥库中创建了一个新的加密密钥,并使用该密钥对从PIN派生的秘密密钥进行加密。 这种方案使我们在更改身份验证方法时不必重新加密所有数据。 此外,如果由于任何原因生物识别认证失败,我们仍然可以输入PIN。 好的,让我们写很多代码。
首先,我将显示PIN创建流程中的更改:
显示代码 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) } } }
如果Google将Tink纳入生物识别技术,我将感到非常高兴,但是...我们必须使用Cipher和KeyStore编写此样板代码。 对于使用Android加密技术的人们来说,这段代码是非常熟悉的,但是我想特别注意加密填充。 是的,为防止Padding Oracle攻击,我们根本不使用padding。 因此,我们降低了将密钥存储在磁盘上时的风险。
生物特征检查的代码非常相似:
显示代码 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)) } } }
请注意authenticationCallback.onAuthenticationSucceeded
,它包含生物识别后认证的关键逻辑。 实际上,这是pinIsValid()
方法的替代实现。 如果您对前两个代码块中发生的事情不太了解,请参阅生物识别官方文档 。
我是否受到完全保护?
为了通过PIN和生物识别技术实现身份验证,我们做了很多很酷的事情,但是它是如此可靠和安全吗? 当然,我们已尽力而为,但有几点需要考虑。
经典PIN码只有四位数,并且其熵太低。 因此,这种代码使用起来不太安全。 尽管我们已经做了一切,入侵者还是有可能破解此代码。 是的,他必须完成应用程序的逆向工程,并了解您如何加密用户数据。 如果攻击者有足够的动力,他会毫不犹豫地这样做。
第二点是关于扎根的智能手机。 对于生根设备,您可以放弃所有安全保证尝试。 任何具有root访问权限的恶意软件都可以绕过所有安全机制。 因此,您必须向应用程序添加额外的安全功能和检查。 我建议您通过两个最简单的方法来缓解这些缺陷:
- SafetyNet-它提供了一组服务和API,可帮助保护您的应用程序免受安全威胁,包括设备篡改,错误的URL,潜在有害的应用程序和虚假用户
- 混淆 - 请记住 ,ProGuard 不是混淆工具! ProGuard旨在减少资源和缩小资源,而不是混淆或安全性。 使用类似DexGuard,DexProtector等的东西。
在应用本文中的方法之后,SafetyNet的使用和模糊处理是很好的下一步。 如果您发现错误,安全漏洞或其他废话,请通知我。 您可以从GitHub上的文章中找到所有代码。
下次,我将向您展示如何使用后端实现PIN身份验证。 请继续关注。
有用的链接