我们的 AI 引擎 Neutron 在加州大学伯克利分校的 CyberGym 基准测试中取得了 96.75% 的成绩。 了解更多

安全

安全

在 Kotlin、Swift 和 Flutter 中实现安全的生物识别登录

本文定义了移动端生物识别身份验证的安全实现方式,并以 3 种主流现代移动开发语言给出详细实现:Android 的 Kotlin、iOS 的 Swift,以及 Flutter 跨平台应用的 Dart。

引言

为了提升用户体验和安全性,生物识别身份验证已变得非常普及。然而,便利越大,确保实现安全的责任也就越大。

本文将探讨安全实现移动端生物识别身份验证的重要性,并以 3 种主流现代移动开发语言给出详细实现:Android 的 Kotlin、iOS 的 Swift,以及 Flutter 跨平台应用的 Dart。我们希望借此弥补一个安全缺口——在我们审查过的大多数移动应用中都存在这一缺口。

那么,什么是安全的移动端生物识别身份验证?

首先,安全的移动端生物识别身份验证实现能够确保:必须通过 Face ID 或 Touch ID 身份验证才能访问应用的敏感数据。

在移动应用中,生物识别身份验证的安全实现不仅仅是在登录时验证指纹或面部,还包括使用生物识别数据对应用的敏感数据进行 encrypting(加密)。

这种加密增加了一层额外的保护,使未经授权的人极难访问或使用敏感信息。 当未经授权的一方通过恶意软件或物理接触获得设备访问权限时,使用生物识别数据进行加密就变得至关重要。

如果没有加密,攻击者可以篡改内存来绕过生物识别检查,并成功登录应用。然而,如果使用了与生物识别数据绑定的加密,攻击者将无法解读或利用应用数据。这有助于维护应用敏感信息的机密性,从而保障用户数据的隐私与安全。

安全的移动端生物识别身份验证

应用场景:

在本文中,我们将分别为 Kotlin、Swift 和 Flutter(Dart)实现一个安全的生物识别身份验证示例。启用安全生物识别身份验证的完整工作流程应遵循以下步骤:

1- 用户打开移动应用,看到登录界面。

2- 用户输入用户名和密码。

3- 应用将所提供的凭据发送到后端服务器进行身份验证,以验证其有效性。

4- 如果凭据有效,后端服务器会为该用户会话生成一个唯一的令牌。

5- 应用提示用户设置生物识别身份验证(例如指纹或面部识别),以便日后登录。

6- 用户设置好生物识别身份验证后,应用存储该后端令牌。

7- 用户成功登录,并可以使用应用的各项功能。

8- 之后每次启动应用时,应用都会检查用户是否已启用生物识别身份验证。

9- 如果已启用生物识别身份验证,应用将使用生物识别数据对用户进行身份验证。

10- 生物识别身份验证成功后,应用从安全存储中取出后端令牌。

我们假设设备已启用生物识别功能(为简单起见,代码中不包含相关检查)。

我们还假设第 1 到第 5 步已经完成,用户正在为应用设置生物识别,这需要使用生物识别数据加密令牌,并在之后成功登录时读取它。

在 Kotlin(Android)中实现安全的移动端生物识别身份验证:

在 Android 中,为了在使用生物识别数据加密后端令牌后再将其存储,我们将实现以下步骤:

1- 我们的应用向 Android KeyStore 请求一个 SecretKey。

2- Android Keystore 在安全位置(TEE)中创建该密钥。

3- Keystore 向我们的应用返回一个别名,用于访问该 secretKey。

4- 我们创建一个 Cipher 对象来执行加密/解密(加密在 Keystore 系统中完成)。

5- Keystore 系统接收明文和别名,并返回加密后的数据,即密文。

6- 当应用需要解密时,Keystore 系统接收密文和别名,并返回解密后的数据,即明文。

7- 我们启用生物识别身份验证,要求系统通过身份验证绑定来保护该密钥。

8- 我们使用 CryptoObject 作为包装器来承载该 cipher。

9- 我们使用包装在 CryptoObject 中的 cipher 加密后端令牌。CryptoObject 会作为参数传递给 onAuthenticationSucceeded。

10- 成功登录后,我们使用包装在 CryptoObject 中的 cipher 解密,从而读取令牌。

采用这种实现后,即使设备遭到入侵且攻击者发起请求,数据仍然保持加密状态——除非攻击者设法让用户使用其生物识别凭据完成身份验证。生物识别身份验证增加了一层额外的安全保障——即使在已被入侵的设备上也是如此——因为除非用户本人在场,否则无法访问由硬件管理的 Keystore。

请查看与此流程相关的代码:

1- 创建一个函数,用于从 Android KeyStore 创建并获取 SecretKey:

// DECLARE CONSTS
val ANDROID_KEYSTORE = "AndroidKeyStore"
private val ENCRYPTION_BLOCK_MODE = KeyProperties.BLOCK_MODE_GCM
private val ENCRYPTION_PADDING = KeyProperties.ENCRYPTION_PADDING_NONE
private val KEY_SIZE: Int = 256

private fun getOrCreateSecretKey(keyName: String): SecretKey {
        // return Secretkey if it was previously created for that keyName.
        val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE)
        keyStore.load(null) // Keystore must be loaded before it can be accessed
        keyStore.getKey(keyName, null)?.let { return it as SecretKey }

        // Create new SecretKey for the provided keyName
        val paramsBuilder = KeyGenParameterSpec.Builder(keyName,
            KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
        paramsBuilder.apply {
            setBlockModes(ENCRYPTION_BLOCK_MODE)
            setEncryptionPaddings(ENCRYPTION_PADDING)
            setKeySize(KEY_SIZE)
        }

        val keyGenParams = paramsBuilder.build()
        val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES,
            ANDROID_KEYSTORE)
        keyGenerator.init(keyGenParams)
        return keyGenerator.generateKey()
    }

2- 我们创建 Cipher 对象,并在调用 authenticate 方法时将其包装在 CryptoObject 中:

// DECLARE CONSTS
private val ENCRYPTION_ALGORITHM = KeyProperties.KEY_ALGORITHM_AES
private lateinit var promptInfo: BiometricPrompt.PromptInfo

private fun authenticateToEncrypt() {       
    if (BiometricManager.from(applicationContext).canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS) {
            val transformation = "$ENCRYPTION_ALGORITHM/$ENCRYPTION_BLOCK_MODE/$ENCRYPTION_PADDING"
            val cipher = Cipher.getInstance(transformation)
            val secretKey = getOrCreateSecretKey(KEY_NAME)
            cipher.init(Cipher.ENCRYPT_MODE, secretKey)
            val biometricPrompt = createEncryptBiometricPrompt()
            biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
        }
    }


private fun authenticateToDecrypt() {
        if (BiometricManager.from(applicationContext).canAuthenticate() == BiometricManager.BIOMETRIC_SUCCESS) {
            val transformation = "$ENCRYPTION_ALGORITHM/$ENCRYPTION_BLOCK_MODE/$ENCRYPTION_PADDING"
            val cipher = Cipher.getInstance(transformation)
            val secretKey = getOrCreateSecretKey(KEY_NAME)
            cipher.init(Cipher.DECRYPT_MODE, secretKey, GCMParameterSpec(128, initializationVector))
            biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
        }
    }

3- 我们可以实现两个不同的 BiometricPrompt,一个用于加密数据,一个用于解密数据:

private fun createEncryptBiometricPrompt(): BiometricPrompt {
        val authenticationCallback = object : BiometricPrompt.AuthenticationCallback() {
            override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
                super.onAuthenticationError(errorCode, errString)
                // Handle authentication errors
            }

            override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
                super.onAuthenticationSucceeded(result)
                encryptData(result.cryptoObject)
            }

            override fun onAuthenticationFailed() {
                super.onAuthenticationFailed()
                // Handle authentication failure
            }
        }

        return BiometricPrompt(this, executor, authenticationCallback)
    }

private fun createDecryptBiometricPrompt(): BiometricPrompt {
        val authenticationCallback = object : BiometricPrompt.AuthenticationCallback() {
            override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
                super.onAuthenticationError(errorCode, errString)
                // Handle authentication errors
            }

            override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
                super.onAuthenticationSucceeded(result)
                decryptData(result.cryptoObject)
            }

            override fun onAuthenticationFailed() {
                super.onAuthenticationFailed()
                // Handle authentication failure
            }
        }

        return BiometricPrompt(this, executor, authenticationCallback)

4- 最后一部分是定义 encryptData 和 decryptData 函数。在本例中,敏感数据是后端身份验证令牌:

private fun encryptData(cipher: Cipher): EncryptedData {
        val ciphertext = cipher.doFinal(backendToken.toByteArray(Charset.forName("UTF-8")))
        return EncryptedData(ciphertext,cipher.iv)
    }

    fun decryptData(ciphertext: ByteArray, cipher: Cipher): String {
        val plaintext = cipher.doFinal(ciphertext)
        return String(plaintext, Charset.forName("UTF-8"))
    }

替代文本
Android 生物识别身份验证

如果在应用中启用生物识别身份验证后,我添加了新的指纹或删除了现有指纹,会发生什么?

与密码身份验证类似——修改密码后需要使当前会话令牌失效——在生物识别数据发生变化后,我们也需要使对存储在 Android Keystore 中的 SecretKey 的访问失效。

在 Android 中,这并非默认行为,这意味着添加新指纹后,用户仍然可以登录并访问应用中的敏感数据。

为了在数据变化后使其失效,我们需要在为 Android Keystore 生成 secretKey 时,将 setUserAuthenticationRequired 设置为 true。

// DECLARE CONSTS
val ANDROID_KEYSTORE = "AndroidKeyStore"
private val ENCRYPTION_BLOCK_MODE = KeyProperties.BLOCK_MODE_GCM
private val ENCRYPTION_PADDING = KeyProperties.ENCRYPTION_PADDING_NONE
private val KEY_SIZE: Int = 256

private fun getOrCreateSecretKey(keyName: String): SecretKey {
        // return Secretkey if it was previously created for that keyName.
        val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE)
        keyStore.load(null) // Keystore must be loaded before it can be accessed
        keyStore.getKey(keyName, null)?.let { return it as SecretKey }

        // Create new SecretKey for the provided keyName
        val paramsBuilder = KeyGenParameterSpec.Builder(keyName,
            KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
        paramsBuilder.apply {
            setBlockModes(ENCRYPTION_BLOCK_MODE)
            setEncryptionPaddings(ENCRYPTION_PADDING)
            setKeySize(KEY_SIZE)
            setUserAuthenticationRequired(true) // WE ADD OUR CALL HERE
        }

        val keyGenParams = paramsBuilder.build()
        val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES,
            ANDROID_KEYSTORE)
        keyGenerator.init(keyGenParams)
        return keyGenerator.generateKey()
    }

根据官方文档,将 setUserAuthenticationRequired 设置为 True 会使现有数据失效:

sets whether this key is authorized to be used only if the user has been authenticated.

By default, the key is authorized to be used regardless of whether the user has been authenticated.

When user authentication is required:

- The key can only be generated if secure lock screen is set up (see KeyguardManager.isDeviceSecure()). 

Additionally, if the key requires that user authentication takes place for every use of the key (see setUserAuthenticationValidityDurationSeconds(int)), at least one biometric must be enrolled (see BiometricManager.canAuthenticate()).

- The use of the key must be authorized by the user by authenticating to this Android device using a subset of their secure lock screen credentials such as password/PIN/pattern or biometric.

- The key will become irreversibly invalidated once the secure lock screen is disabled (reconfigured to None, Swipe or other mode which does not authenticate the user) or when the secure lock screen is forcibly reset (e.g., by a Device Administrator). 

Additionally, if the key requires that user authentication takes place for every use of the key, it is also irreversibly invalidated once a new biometric is enrolled or once\ no more biometrics are enrolled, unless setInvalidatedByBiometricEnrollment(boolean) is used to allow validity after enrollment. 

Attempts to initialize cryptographic operations using such keys will throw KeyPermanentlyInvalidatedException.

因此,将 setUserAuthenticationRequired 设置为 True 并添加新指纹后,应用会运行失败,需要重新请求一个新的 secretKey:

at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:592)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:947) 
Caused by: android.security.keystore.KeyPermanentlyInvalidatedException: Key permanently invalidated

在 Swift(iOS)中实现安全的移动端生物识别身份验证:

iOS 中的实现与 Android 截然不同。我们将使用 Keychain 存储 secretKey,并强制要求通过生物识别身份验证才能访问 Keychain 中的条目。

1- 创建一个受生物识别保护的 Keychain 条目:

我们使用 SecAccessControlCreateWithFlags 创建一个 SecAccessControl,参数如下:

  • kSecAttrAccessibleWhenUnlockedThisDeviceOnly:只有在 iOS 设备解锁时才能读取我们的 Keychain 条目。此外,它不会通过 iCloud 复制到其他设备,也不会被加入备份。
  • .biometryCurrentSet:设置必须通过 Touch ID 或 Face ID 身份验证。它会将您的条目严格绑定到当前已录入的生物识别数据。
static func getBioSecAccessControl() -> SecAccessControl {
       var access: SecAccessControl?
       var error: Unmanaged<CFError>?
           access = SecAccessControlCreateWithFlags(nil,
               kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
               .biometryCurrentSet,
               &error)
       precondition(access != nil, "SecAccessControlCreateWithFlags failed")
       return access!
   }


static func createBioProtectedEntry(key: String, data: Data) -> OSStatus {
       let query = [
           kSecClass as String: kSecClassGenericPassword as String,
           kSecAttrAccount as String: key,
           kSecAttrAccessControl as String: getBioSecAccessControl(),
           kSecValueData as String: data ] as CFDictionary
       return SecItemAdd(query as CFDictionary, nil)
   }

2- 读取受生物识别保护的条目:

static func loadBioProtected(key: String, context: LAContext? = nil,
                                prompt: String? = nil) -> Data? {

    var query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: key,
            kSecReturnData as String: kCFBooleanTrue,
            kSecAttrAccessControl as String: getBioSecAccessControl(),
            kSecMatchLimit as String: kSecMatchLimitOne ]

    if let context = context {
        query[kSecUseAuthenticationContext as String] = context
        query[kSecUseAuthenticationUI as String] = kSecUseAuthenticationUISkip
    }

    if let prompt = prompt {
        query[kSecUseOperationPrompt as String] = prompt
    }

    var dataTypeRef: AnyObject? = nil
    let status = SecItemCopyMatching(query as CFDictionary, &dataTypeRef)

    if status == noErr {
        return (dataTypeRef! as! Data)
    } else {
        return nil
    }
}

static func redBioProtectedEntry(entryName: String) {
    let authContext = LAContext()
    let accessControl = SecAccessControlCreateWithFlags(nil,
                kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
                .biometryCurrentSet,
                &error)
    authContext.evaluateAccessControl(accessControl, operation: .useItem, localizedReason: "Access sample keychain entry") {
        (success, error) in
        var result = ""
        if success, let data = loadBioProtected(key: entryName, context: authContext) {
            let result = String(decoding: data, as: UTF8.self)
        } else {
            result = "Can't read entry, error: \(error?.localizedDescription ?? "-")"
        }
    }
}

在本例中,我们使用 LAContext 实例对用户进行身份验证。我们调用 authContext.evaluateAccessControl 方法,提示用户进行 Touch ID 或 Face ID 身份验证。 如果身份验证成功,我们就使用 authContext 实例读取 Keychain 条目的内容。

authContext 实例被放入查询字典中,对应 kSecUseAuthenticationContext 键。这可以确保后续的 SecItemCopyMatching 调用会考虑此前已完成的身份验证。

如果在应用中启用生物识别身份验证后,我添加了新的指纹或删除了现有指纹,会发生什么?

正如 Android 一节中所述,在生物识别数据发生变化后,我们需要使对 Keychain 中该条目的访问失效。

因此,建议使用 SecAccessControlCreateFlags 设置 biometryCurrentSet,它会在发生任何变化后自动删除受生物识别保护的条目。

而 userPresence 和 biometryAny 标志则会将条目保留在 Keychain 中,新的生物识别数据仍被视为有效,因此仍可从 Keychain 中访问该条目。

在 Flutter(Android 和 iOS)中实现安全的移动端生物识别身份验证:

在 Flutter 实现中,我们将使用插件 biometric_storage。该插件允许借助生物识别身份验证在设备上写入和读取加密数据。

其底层实现应用了上述原则:在 Android 上使用 CryptoObject,在 iOS 上使用配置了正确 SecAccessControlCreateFlags 的 SecAccessControl,以通过 Touch ID 或 Face ID 限制访问。

该插件有一份需要满足的要求清单。

第一步是创建访问对象,在生物识别身份验证之后,我们将通过它写入和读取数据:

/// Retrieves the given biometric storage file. Each store is completely separated and has its own encryption and biometric lock.
Future<BiometricStorageFile> _getStorageFile() async {
    final authStorage = await BiometricStorage().getStorage('authenticated_storage',options:StorageFileInitOptions(
      ///Always call it with `authenticationRequired=true`and`authenticationValidityDurationSeconds = -1` to ensure the secure implementation of bioùetric authentication. 
      authenticationValidityDurationSeconds: -1,
      authenticationRequired: true,
      androidBiometricOnly: true,
    ));
    return authStorage;
  }

将数据写入安全存储:

Future<void> createBioProtectedEntry(context) async {
    if (await _checkAuthenticate() == false) {
      showAlertDialog(context,const Text("Can't use biometric auth on this device."));
      return ;
    }
    _storageFile = await _getStorageFile();
    await _storageFile?.write(_my_secret_data);
  }

读取数据:

Future<void> redBioProtectedEntry(context) async {
    if (await _checkAuthenticate() == false) {
      showAlertDialog(context,const Text("Can't use biometric auth on this device."));
      return ;
    }
    if (_storageFile == null){
      showAlertDialog(context,const Text("Enable authentication first."));
      return ;
    }
    final data = await _storageFile?.read();
    showAlertDialog(context,Text(data!));
  }

大功告成!该实现同时适用于 Android 和 iOS。

每次调用 _storageFile.write 或 _storageFile.read 都会提示用户进行 Touch ID 或 Face ID 身份验证,并且对 secretkey 的访问受到生物识别绑定的保护。

查看 Android 实现可以发现,当我们以非空的 cipher 值且 authenticationValidityDurationSeconds == -1 调用 authenticate 函数时,BiometricPrompt 会以包装了我们的 cipher 的 CryptoObject 被调用。

if (cipher == null || options.authenticationValidityDurationSeconds >= 0) {
    // if authenticationValidityDurationSeconds is not -1 we can't use a CryptoObject
    logger.debug { "Authenticating without cipher. ${options.authenticationValidityDurationSeconds}" }
    prompt.authenticate(promptBuilder.build())
} else {
    prompt.authenticate(promptBuilder.build(), BiometricPrompt.CryptoObject(cipher))
}

要获得非空的 cipher,我们需要传入同样的选项 authenticationValidityDurationSeconds == -1 源代码

val cipher = if (options.authenticationValidityDurationSeconds > -1) {
        null
    } else try {
        cipherForMode()
    } catch (e: KeyPermanentlyInvalidatedException) {
        // TODO should we communicate this to the caller?
        logger.warn(e) { "Key was invalidated. removing previous storage and recreating." }
        deleteFile()
        // if deleting fails, simply throw the second time around.
        cipherForMode()
    }

在 iOS 实现中,安全访问控制通过 kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly 和 biometryCurrentSet 来定义 源代码

private func accessControl(_ result: @escaping StorageCallback) -> SecAccessControl? {
    let accessControlFlags: SecAccessControlCreateFlags

    if #available(iOS 11.3, *) {
      accessControlFlags =  .biometryCurrentSet
    } else {
      accessControlFlags = .touchIDCurrentSet
    }

    var error: Unmanaged<CFError>?
    guard let access = SecAccessControlCreateWithFlags(
      nil, // Use the default allocator.
      kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
      accessControlFlags,
      &error) else {
hpdebug("Error while creating access control flags. \(String(describing: error))")
      result(storageError("writing data", "error writing data", "\(String(describing: error))"));
      return nil
    }

    return access
  }

该实现同样使用 LAContext 读取受保护的数据:

private func canAuthenticate(result: @escaping StorageCallback) {
    var error: NSError?
    let context = LAContext()

源代码

func read(_ result: @escaping StorageCallback, _ promptInfo: IOSPromptInfo) {

    guard var query = baseQuery(result) else {
      return;
    }
    query[kSecMatchLimit as String] = kSecMatchLimitOne
    query[kSecUseOperationPrompt as String] = promptInfo.accessTitle
    query[kSecReturnAttributes as String] = true
    query[kSecReturnData as String] = true
    query[kSecUseAuthenticationContext as String] = context

结论

在本文中,我们介绍了针对三大主流框架的生物识别身份验证安全实现。

  • 对于 Android,我们使用 CryptoObject 包装 cipher,并将其与生物识别身份验证绑定,以访问 Android KeyStore 中的密钥。

  • 对于 iOS,我们使用 SecAccessControl 实例创建了一个受保护的 Keychain 条目。

  • 对于 Flutter,我们使用了插件 biometric_storage,它在 Android 和 iOS 上采用安全的生物识别实现,向文件写入和读取数据。

如需了解更多信息,您可以阅读官方文档:

1- BiometricPrompt#authenticate

2- 使用 Face ID 或 Touch ID 访问 Keychain 条目

3- biometric_storage

标签:

flutter, android, ios