28. Keystore与FIDO2:WebAuthn集成、CTAP协议、U2F密钥对生成、认证器注册

聊到移动端安全,FIDO2 是个绕不开的话题。说白了,它就是要干掉密码。我最早接触 FIDO 标准是在做企业级身份认证 SDK 的时候,客户要求支持无密码登录。当时我还在想,这玩意儿跟 Android Keystore 有什么关系?后来才发现,Keystore 就是 FIDO2 在 Android 上的硬件根基。

这一章,我们就把 FIDO2 的三大组件拆开来看:WebAuthn、CTAP、U2F。重点落在 Android Keystore 如何为它们提供密钥对生成和认证器注册的能力。

28.1 FIDO2 协议栈概览

先搭个框架。FIDO2 由两个核心规范组成:

  • WebAuthn(Web Authentication):浏览器端的 JavaScript API,负责与服务器通信。
  • CTAP(Client to Authenticator Protocol):客户端(浏览器/App)与认证器(硬件密钥/手机 TEE)之间的通信协议。

而 U2F 是 FIDO 第一代标准,现在被 FIDO2 兼容。Android 从 7.0 开始内置了 FIDO2 认证器,底层就是 Keystore + TEE。

核心关系:WebAuthn 是上层接口,CTAP 是传输协议,U2F 是密钥对生成规范。Android Keystore 是它们的硬件安全底座。

FIDO2 协议栈与 Android Keystore 关系 WebAuthn (浏览器 API) navigator.credentials.create() / get() CTAP 协议 CTAP1 (U2F) / CTAP2 (FIDO2) Android 认证器 (FIDO2 Authenticator) Keystore + TEE + 生物识别 Android Keystore (硬件安全模块)

28.2 WebAuthn 集成:从浏览器到 Keystore

WebAuthn 的流程,我简化一下:

  1. 用户点击「登录」→ 服务器下发 challenge(随机数)。
  2. 浏览器调用 navigator.credentials.create()
  3. Android 系统弹出认证器选择(内置 / 外接)。
  4. 认证器调用 Keystore 生成非对称密钥对。
  5. 返回公钥 + 签名给服务器。

这里的关键是第 4 步。Android 内置认证器会调用 KeyStoregenerateKeyPair(),并且指定 KeyProperties.PURPOSE_SIGN。我习惯在生成时加上 setUserAuthenticationRequired(true),这样每次签名都需要用户验证指纹或锁屏。

我的经验:WebAuthn 的 challenge 必须足够随机,长度至少 32 字节。曾经有个项目用了 8 字节的 challenge,被安全审计直接打回。服务器端也要验证 challenge 的时效性,防止重放攻击。

28.3 CTAP 协议:客户端与认证器的桥梁

CTAP 分两个版本:

版本 对应标准 传输方式 Android 支持
CTAP1 U2F USB / NFC / BLE Android 7.0+
CTAP2 FIDO2 USB / NFC / BLE Android 10+

CTAP 的消息格式是 CBOR 编码的。Android 平台通过 Fido2ApiClient 封装了这些细节。你不需要直接处理 CTAP 报文,但理解它的结构有助于调试。

举个例子,CTAP2 的 authenticatorMakeCredential 命令包含:

  • clientDataHash:WebAuthn 的 challenge 哈希
  • rp:依赖方(你的服务器)信息
  • user:用户信息(id、name、displayName)
  • pubKeyCredParams:支持的算法类型(通常是 ES256)
  • excludeList:排除已有的凭证 ID

Android 的 FIDO2 API 会把这些参数映射到 Keystore 的密钥生成参数上。嗯,这里要注意:pubKeyCredParams 如果指定了 -7(ES256),Keystore 就会生成 EC P-256 密钥对。

28.4 U2F 密钥对生成:Keystore 的硬核操作

U2F 的密钥对生成,说白了就是 Keystore 的 generateKeyPair 加上一些 FIDO 特定的参数。我直接贴一段代码,这是我在实际项目中用过的:

// 生成 U2F 兼容的密钥对
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance(
    KeyProperties.KEY_ALGORITHM_EC, "AndroidKeyStore");

KeyGenParameterSpec.Builder builder = new KeyGenParameterSpec.Builder(
    "fido2_key_" + System.currentTimeMillis(),
    KeyProperties.PURPOSE_SIGN)
    .setAlgorithmParameterSpec(new ECGenParameterSpec("secp256r1"))
    .setDigests(KeyProperties.DIGEST_SHA256)
    .setUserAuthenticationRequired(true)
    .setUserAuthenticationValidityDurationSeconds(30)
    .setAttestationChallenge(challenge);  // 关键:传入 challenge

keyPairGenerator.initialize(builder.build());
KeyPair keyPair = keyPairGenerator.generateKeyPair();

这里有个坑:setAttestationChallenge() 是 Android 8.0 才加入的。如果你要兼容低版本,得用 KeyStoresetEntry() 手动注入 attestation 证书链。我曾经在 Android 7.1 的设备上踩过这个坑,折腾了两天才找到替代方案。

注意:U2F 要求密钥对必须绑定到特定的 AppID(WebAuthn 里叫 RP ID)。Android Keystore 的 setAttestationChallenge 可以携带这个信息,但你需要自己实现绑定逻辑。不要偷懒把 AppID 硬编码在代码里,否则换域名就全废了。

28.5 认证器注册:从生成到上报

认证器注册的完整流程,我画个图帮你理解:

认证器注册流程 1. 接收 challenge 2. 生成密钥对 3. 生成 attestation 4. 签名 challenge 5. 构造凭证 6. 返回给客户端 7. 服务器验证 8. 存储公钥 9. 注册完成 绿色:密钥生成阶段 | 黄色:凭证构造阶段 | 紫色:服务器验证阶段

注册完成后,认证器会返回一个 AuthenticatorAttestationResponse,里面包含:

  • attestationObject:包含公钥、凭证 ID、attestation 证书链
  • clientDataJSON:客户端数据(challenge、origin、type)

服务器收到后,需要验证 attestation 证书链是否可信。Android 的 attestation 证书由 Google 的 TEE 签名,你可以用 GoogleAttestationVerifier 来验证。我建议在服务器端缓存证书吊销列表,因为 Google 偶尔会更新签名密钥。

避坑指南:我曾经遇到过一个 case,某款手机的 TEE 固件版本太老,生成的 attestation 证书链不完整。解决方案是在客户端检测 isUserAuthenticationValid() 方法,如果返回 false,就提示用户更新系统安全补丁。

28.6 总结:Keystore 在 FIDO2 中的角色

最后总结一下。Android Keystore 在 FIDO2 里扮演了三个角色:

  1. 密钥生成器:生成符合 U2F/FIDO2 规范的 EC P-256 密钥对。
  2. 签名引擎:对 challenge 进行签名,证明用户持有私钥。
  3. attestation 提供者:提供硬件级证明,告诉服务器「这个密钥确实是在安全硬件里生成的」。

说白了,没有 Keystore,Android 的 FIDO2 认证器就是个软件模拟,安全性大打折扣。你想想看,如果私钥能被 App 直接读取,那跟用密码有什么区别?

我个人习惯在实现 FIDO2 时,始终把 Keystore 的 setUserAuthenticationRequired(true) 打开。虽然用户体验上多了一步验证,但安全性提升了一个量级。毕竟,FIDO2 的初衷就是「something you have + something you are」,缺一不可。


公众号:蓝海资料掘金营,微信deep3321