iOS与Android平台基于硬件绑定密钥的生物特征升级认证
仅靠面容识别无法向服务器证明任何信息。应使用安全隔区与 Android 密钥库中的密钥来签署一次性挑战码,从而实现真正的增强级身份验证。
来源: Biometrics, the Secure Enclave, and Auth Tokens: Three Ideas That Sound the Same But Aren’t
产品规格说明中常写道“Face ID 可用于解锁应用”,对用户而言这样的描述已经足够。但对于需要构建支付功能、健康记录系统、管理控制台或登录流程的团队来说,这种描述却掩盖了关键点:生物特征扫描数据永远不会传送到后端,也不会生成任何令牌,更不会将面部或指纹模板传递给应用程序代码。一个设计良好的应用实际上获得的是使用受保护硬件的权限,该硬件能运用应用程序代码无法读取的私钥,从而生成服务器可验证的一次性验证码。本指南将容易混淆的三个概念——生物特征识别、硬件级密钥以及认证令牌——区分开来,并详细介绍了两个平台上的注册后再签名流程。
为何仅靠令牌在移动设备上无法提供可靠的安全保障
移动应用运行在用户控制的硬件上,而非你控制的设备。手机可能丢失,锁屏画面可能被旁人窥视,开发过程中还可能会安装调试工具,而越狱或root过的设备则可能暴露比你希望的更多系统信息。
尽管存在这些风险,敏感的终端仍需信任一个明确的声明:该调用来自持有服务器早先注册的密钥、且刚刚由授权人员使用过该密钥的手机。
这与“此请求包含令牌”相比有何不同?任何存储在应用内存或存储中的数据都可能被复制并从其他地方重新使用。而那些在受保护硬件中生成且绝不能导出的私钥则难以窃取,因为要获取它必须攻击硬件本身,而非简单读取字符串。
在开始构建任何系统之前,先明确每部分需要证明的内容会很有帮助:
- 生物特征识别提示:手机支持 Face ID、Touch ID 或强大的 Android 生物特征识别功能。
- 安全硬件密钥:经过保护的私钥用于生成签名。
- 认证令牌:您的服务器授予会话权限或某些访问权限。
一个完善的设计会同时使用这三种方式,各自承担不同的功能。
HTTPS 保护的是通信渠道而非身份
HTTPS 会对应用与 API 之间的数据流进行加密,因此使用咖啡馆 Wi-Fi 网络的人无法轻易读取或篡改请求内容。
phone ── encrypted HTTPS connection ──► server
这是必需的,但单独使用并不足够。如果攻击者已经获得了有效的令牌,TLS 仍会将该被盗令牌安全地传输到您的服务器。在加密连接中没有任何信息能告诉后端该令牌来自合法设备还是副本。
因此基本原则是:在每次调用时都使用 HTTPS,以常规方式验证证书,保持 TLS 栈得到及时修补,再在其上添加一种认证机制,用以证明设备及用户操作的可验证性。
证书固定是一种功能有限的工具
证书固定会将应用限制在已知的证书或公钥身份范围内。在管控严格的应用中,它能够减少某些中间人攻击场景的发生。
但它无法让承载令牌具备硬件支持的凭证功能。此外它还存在实际的操作风险:如果固定的证书有误,或未能及时更新证书,所有安装该应用的用户都将立即无法与您的 API 进行通信。只有当您的威胁模型需要时,且发布流程能够可靠地处理密钥更新,才应采用此方法,绝不能将其视为设备绑定认证的替代方案。
生物特征识别提示仅是一个本地的“是”或“否”答案
在 iOS 上,应用会请求 LAContext 显示系统身份验证界面;在 Android 上,对应的则是 BiometricPrompt。操作系统负责进行扫描并反馈成功或失败的结果。下面的代码片段是 Swift 语言:它首先评估仅基于生物特征识别的策略并附带原因说明,若检查通过则开始数据传输。
let context = LAContext()
let policy = LAPolicy.deviceOwnerAuthenticationWithBiometrics
context.evaluatePolicy(policy, localizedReason: "Approve this transfer") { success, error in
guard success else { return }
startTransfer()
}
你的代码根本无法获取面部几何信息或指纹图像。苹果指出,LocalAuthentication 是与安全隔区协同工作的,只会向应用返回通过/失败的结果(LocalAuthentication 框架参考)。
这对于保护应用内的私有内容已经足够了。但作为向服务器提供证据而言,它存在三个不足之处:
- 结果仅显示“设备已接受验证”,并未说明是哪个人或哪个账户。
- 后端无法验证客户端生成的布尔值。
- 如果应用程序进程被攻破,攻击者可以跳过或伪造回调,直接重复使用已存在的令牌。
从这种模式中可以得出的结论是:success仅仅是你自己内存空间中的一个标志位。更完善的设计会利用生物特征验证来解锁单一的加密操作,其输出可由服务器独立验证。
硬件密钥存储可将私钥与应用程序进程隔离开来
苹果设备配备了安全隔区,这是一种隔离式的子系统,能够生成并使用私钥,而不会将原始密钥信息暴露给主应用程序处理器。根据苹果的文档说明,密钥始终保留在安全隔区内,应用只能收到签名等输出结果(苹果安全隔区密钥指南)。
另一个平台上的对应功能是Android Keystore。根据硬件不同,密钥可能由可信执行环境(TEE)或专用的StrongBox安全模块来保护。在此处命名时需格外注意:“Titan M”是谷歌Pixel手机中的芯片,并非通用的Android API,因此不能假设所有设备都配备该芯片。从应用的角度来看,重要的是密钥的存储方式以及所设定的使用规则。
对于高价值操作,密钥需满足以下四项要求:
- 必须在当前特定设备上生成。
- 每次使用都需要重新进行生物特征验证。
- 私钥绝不能被导出。
- 服务器上仅存储公钥。
在 Android 系统中,只有当认证器类别为 BIOMETRIC_STRONG 时,BiometricPrompt.CryptoObject 才能对加密操作进行控制,同时还可以配置密钥以便每次使用时都要求重新授权(BiometricPrompt 参考文档,密钥库概述)。
该协议:一次性注册密钥,之后对新的挑战进行签名
整个方案分为两个阶段:注册和使用。
注册
- 应用生成由硬件支持的密钥对。
- 通过 HTTPS 将公钥上传到后端。
- 服务器将公钥与账户信息及设备标识符一起存储起来。
- 私钥则保留在手机内的安全硬件中。
发送公钥并无危害:它只能用于验证签名,无法生成签名。只有私钥才能进行签名,且私钥永远不会离开受保护的硬件设备。
批准敏感请求
- 应用程序向服务器请求一次性的随机挑战信息。
- 用户完成生物特征识别验证。
- 安全硬件设备对挑战信息进行签名。
- 应用程序返回挑战信息编号及签名结果。
- 服务器使用存储的公钥验证该签名。
- 服务器决定接受或拒绝该操作,随后可能会发放令牌。
挑战在于大多数实现方式都会出错。该签名必须具有随机性、能快速过期,并且只能使用一次。它还应与账户、特定的设备密钥以及正在被审批的操作绑定(例如“向账户X转账200欧元”)。如果缺少其中任何一项,被获取的签名就可能在日后被用于其他请求,这就是典型的重放攻击。将操作绑定还可以防止为无害操作收集的签名被用于危险的操作。
iOS:每次使用都需要面容识别的安全隔区密钥
在 iOS 系统中,密钥是在安全隔区内部直接创建的,并会关联一个访问控制对象,该对象要求每次使用密钥时都必须进行生物特征识别。在下面的 Swift 代码中,可以看到三种可选设置。kSecAttrAccessibleWhenUnlockedThisDeviceOnly 可防止密钥被同步到备份文件或其他设备中。.privateKeyUsage 和 .biometryCurrentSet 这两个标志要求在签名时进行生物特征验证,并将密钥与当前已注册的面容或指纹绑定。kSecAttrTokenIDSecureEnclave 则负责将密钥存储在硬件中;由于安全隔区仅支持 256 位椭圆曲线(P-256)密钥,因此密钥的类型和大小才被设定为如此。
// 1. Create access control requiring Face ID / Touch ID for every use
var error: Unmanaged<CFError>?
guard let accessControl = SecAccessControlCreateWithFlags(
kCFAllocatorDefault,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
[.privateKeyUsage, .biometryCurrentSet],
&error
) else { return }
// 2. Generate the key pair in the Secure Enclave
let attributes: [String: Any] = [
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
kSecAttrKeySizeInBits as String: 256,
kSecAttrTokenID as String: kSecAttrTokenIDSecureEnclave,
kSecPrivateKeyAttrs as String: [
kSecAttrIsPermanent as String: true,
kSecAttrApplicationTag as String: "com.example.keys.transfer",
kSecAttrAccessControl as String: accessControl
]
]
guard let privateKey = SecKeyCreateRandomKey(attributes as CFDictionary, &error) else { return }
.biometryCurrentSet带来的实际影响是,每当注册新的指纹或重置Face ID时,对应的密钥都会失效。对于支付功能而言这通常是理想的情况,但这也意味着应用必须妥善处理密钥缺失的问题,并引导用户重新注册。
在用户完成常规账户登录后,先上传公钥。之后当后端发送用于转账的随机挑战信息时,使用私钥对其进行签名。无需手动显示提示框:由于访问控制机制的存在,签名操作会自动触发Face ID或Touch ID的验证。
let algorithm: SecKeyAlgorithm = .ecdsaSignatureMessageX962SHA256
var signError: Unmanaged<CFError>?
// 3. This call automatically triggers the Face ID prompt because of the accessControl flags
guard let signature = SecKeyCreateSignature(
privateKey,
algorithm,
challengeData as CFData,
&signError
) else { return }
api.approve(challengeId, signature)
如果用户取消扫描或扫描失败,SecKeyCreateSignature会返回nil,此时没有可发送的签名,因此攻击者也无法篡改任何成功标志。
Android:通过BiometricPrompt解锁密钥库中的密钥
下面的 Kotlin 代码在 AndroidKeyStore 提供程序中创建一个仅用于签名的 EC 密钥。setUserAuthenticationRequired(true) 会强制要求进行身份验证,而 setUserAuthenticationParameters(0, AUTH_BIOMETRIC_STRONG) 则将有效时间设置为零秒,这意味着每次使用都需要重新进行强生物特征识别验证,而无需在上次解锁后享有宽限期。
val spec = KeyGenParameterSpec.Builder(
"transfer-key",
KeyProperties.PURPOSE_SIGN
).setDigests(KeyProperties.DIGEST_SHA256)
.setUserAuthenticationRequired(true)
.setUserAuthenticationParameters(
0,
KeyProperties.AUTH_BIOMETRIC_STRONG
).build()
val generator = KeyPairGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_EC,
"AndroidKeyStore"
)
generator.initialize(spec)
val publicKey = generator.generateKeyPair().public.encoded
在用户正常登录后,将编码后的 publicKey 发送到后端,并将其与用户及设备记录一起存储。
当服务器随后发出传输相关的验证请求时,先创建一个 Signature 对象,将其封装在 CryptoObject 中,再将该对象传递给验证界面。只有在 onAuthenticationSucceeded 方法中,才需要输入验证请求字节并生成签名。
val signature = Signature.getInstance("SHA256withECDSA")
signature.initSign(privateKey)
val crypto = BiometricPrompt.CryptoObject(signature)
prompt.authenticate(promptInfo, crypto)
override fun onAuthenticationSucceeded(result: AuthenticationResult) {
val signed = result.cryptoObject!!.signature!!
.apply { update(challenge) }.sign()
api.approve(challengeId, signed)
}
对话框并非安全边界,真正的安全边界是CryptoObject。除非与其关联的生物特征识别检查成功,否则密钥库会拒绝执行签名操作,且密钥材料本身始终保存在受保护的存储中。您的应用仅持有生成的签名。
在服务器端,使用注册的公钥验证该签名。如果它与未过期且未被使用的挑战值匹配,则批准转账操作,或为该操作生成一个有效期短暂的令牌。
认证令牌应存放的位置
验证硬件支持的签名并不会消除令牌的存在。检查之后,发放常规访问令牌是完全合理的,因为令牌依然是授权大部分 API 流量的实用方式。如果您正在设计系统的令牌相关部分,我们的刷新令牌策略指南中的权衡原则同样适用。
应将这些令牌存储在平台提供的受保护存储空间中:iOS 的 Keychain 以及 Android 的 Keystore 存储。长期有效的刷新令牌绝不能存放在普通的偏好设置、普通文件或 JavaScript 可访问的存储空间中,这一点在 React Native 应用中尤为重要,因为该框架默认使用的 AsyncStorage 并未经过加密。
接下来的设计问题就是应多频繁地要求重新生成硬件签名:
- 每次调用 API 时:几乎总是速度过慢且令人烦恼。
- 每次启动应用时:作为私人应用锁是一种合理的选择。
- 在支付、编辑个人资料、导出数据或批准新设备时:通常是最佳方案。
- 每次使用刷新令牌时:对于高风险账户来说是值得的,前提是用户体验能够承受相关提示。
可以将签名视为强化验证,而令牌则是由此产生的短期权限。
硬件支持密钥的局限性
这种方法很有效,但并非万能:
- 对于那些自行解锁手机并在社会工程学压力下批准有害操作的合法用户,它毫无作用。
从一开始就规划好恢复路径,并使其比日常使用路径更慢、需要更多的验证步骤。当设备密钥丢失、锁屏被重置或生物特征识别信息发生变化时,您的应用应能够立即注册新的密钥,同时服务器也应撤销旧的密钥。根据配置方式的不同,Android文档指出,当安全锁屏被更改或生物特征识别信息发生变化时,与身份验证相关的密钥可能会被永久失效(参见KeyProtection.Builder参考文档)。
通行密钥与这种设计的关系
通行密钥同样基于相同的基础:一对公私钥,以及用私钥签名的服务器挑战信息。对于网站和账户登录而言,它们通常是最佳解决方案,因为操作系统和平台会处理大部分复杂工作。通行密钥是一种标准化的跨平台协议,建立在这种由硬件支持的签名流程之上。有一点需要注意:许多通行密钥会通过平台的凭证管理器在用户的不同设备之间同步,因此它们并不像不可导出的密钥存储或安全隔区密钥那样始终绑定在某一部手机上。
这种区分意味着一种简单的划分:当原生应用需要为特定操作获取与设备绑定的验证凭证时,应使用硬件支持的密钥;而在产品支持的情况下,则可使用通行密钥实现现代的无密码登录。无论哪种方式,服务器的任务都是相同的:使用其已信任的公钥来验证签名。
为不同需求选择合适的工具
- 保护传输中的数据:HTTPS。
- 保持用户登录状态:使用短期有效的访问令牌以及受保护的刷新令牌。
- 在应用启动时锁定私人内容:Face ID、Touch ID或BiometricPrompt。
- 批准支付或其他高风险操作:通过生物特征验证的硬件签名。
- 无密码登录:通行密钥。
- 降低TLS拦截风险:证书固定机制。
核心要点
Face ID和指纹扫描并不能验证后端请求的真实性。它们只是用于授权密钥的使用;安全硬件会利用该密钥签署新的服务器挑战信息,而服务器在决定用户可执行的操作之前会先验证该签名。
- 生物特征:本地审批,对服务器不可见。
- 硬件密钥:拥有证明,且无法从设备中复制出来。
- 令牌:服务器授予的短期权限。
应让这些机制各司其职,将每个挑战与特定的账户、密钥和操作绑定,并做好密钥失效及恢复的规划。如此一来,移动端认证设计不仅更易于理解,也大大降低了伪造的可能性。