2. FIDO2核心架构与术语体系

要真正理解无密码认证,不能只记住“指纹登录”或“刷脸登录”这样的表面印象。FIDO2 是一套由多个规范、角色和协议层共同组成的认证体系。它把“用户如何证明自己是本人”这件事,拆解成浏览器、操作系统、认证器硬件、依赖方服务器之间的一次标准化协作。掌握这套架构,才能在后续部署、排障和开发中准确判断问题出在哪一层。

一、FIDO2 的两大支柱:WebAuthn 与 CTAP2

FIDO2 并不是单一协议,而是由两个规范共同支撑:WebAuthn(Web Authentication)和 CTAP2(Client to Authenticator Protocol 2)。

WebAuthn 由 W3C 制定,负责定义浏览器(更准确说是“客户端”)与依赖方服务器之间的接口。它规定了 JavaScript API 的调用方式、公钥凭证的数据结构、挑战值的生成与校验规则,以及断言结果的格式。开发者在前端调用 navigator.credentials.create() 和 navigator.credentials.get(),背后就是 WebAuthn 在起作用。

CTAP2 由 FIDO 联盟制定,负责定义客户端与认证器之间的通信协议。当浏览器需要让一枚 USB 安全密钥、一台手机的指纹模块或一个平台内置的 TPM 完成密钥生成或签名时,就通过 CTAP2 发送指令。CTAP2 支持 USB、NFC、BLE 以及平台内部通道等多种传输方式。

两者的分工可以这样理解:WebAuthn 面向“网页与服务器”,CTAP2 面向“客户端与硬件”。它们共同构成一条从用户手指到服务器数据库的完整信任链。

依赖方服务器 Relying Party (RP) 存储公钥凭证 生成挑战值 浏览器 / 客户端 WebAuthn API navigator.credentials CTAP2 客户端 认证器 Authenticator 生成密钥对 私钥永不外泄 WebAuthn CTAP2 注册流程(Registration / Attestation) ① RP 下发 challenge + RP ID ② 客户端调用 create() ③ 认证器生成 新密钥对 ④ 返回公钥凭证 credentialId + publicKey 认证流程(Authentication / Assertion) ① RP 下发 新 challenge ② 客户端调用 get() ③ 认证器用私钥 签名 challenge ④ RP 用公钥验签 通过则登录成功

二、五个必须掌握的核心角色

在 FIDO2 的语境中,每个参与者都有明确的名字,混淆它们会导致沟通和排障困难。

  • 依赖方(Relying Party,RP):需要验证用户身份的服务方,例如企业门户、银行 App 后端。RP 负责生成挑战值、存储公钥凭证、验证签名。RP 的身份由 RP ID 标识,通常是一个域名,如 login.example.com。RP ID 决定了凭证的适用范围,子域名可以共享父域名的 RP ID,但跨域则不行。
  • 客户端(Client):通常是浏览器或操作系统提供的认证平台。它负责调用 WebAuthn API、与认证器通过 CTAP2 通信、把结果整理成标准格式返回给 RP。客户端本身不持有私钥,也不做信任判断。
  • 认证器(Authenticator):真正生成密钥对并执行签名的实体。它可以是 USB 安全密钥(如 YubiKey)、手机上的平台认证器(Touch ID、Windows Hello)、或跨设备认证器(手机扫码登录电脑)。认证器的核心安全属性是:私钥在认证器内部生成,且永远不离开认证器。
  • 用户(User):通过生物特征、PIN 或按键触摸向认证器授权的人。用户验证(User Verification,UV)和用户存在(User Presence,UP)是两个不同概念:UP 只证明“有人在设备旁”,UV 则证明“是合法用户本人”。
  • 公钥凭证(Public Key Credential):注册完成后,RP 保存的那份数据。它包含 credentialId、公钥、签名计数器、AAGUID 等字段。认证器保存私钥,RP 保存公钥,两者通过 credentialId 关联。

三、挑战-响应:为什么每次登录都不同

FIDO2 的安全性核心之一,是每次认证都使用一次性的挑战值(Challenge)。挑战值由 RP 生成,通常是一个至少 16 字节的随机数,经过 Base64URL 编码后下发给客户端。

认证器在签名时,会把挑战值、RP ID、客户端数据等一起纳入签名范围。RP 收到断言后,用保存的公钥验证签名,同时检查挑战值是否与自己刚发出的那个一致。由于挑战值每次不同,攻击者即使截获了上一次的签名数据,也无法重放——因为下一次挑战值已经变了。

这里有一个容易忽略的细节:挑战值必须由 RP 在服务端生成并保存,不能由前端 JavaScript 自己造。如果挑战值可预测或可复用,整个防重放机制就失效了。

四、注册与认证的数据流对比

注册和认证都遵循“RP 发起 → 客户端中转 → 认证器执行 → 结果回传”的路径,但目的和产物不同。

对比项 注册(Attestation) 认证(Assertion)
核心 API navigator.credentials.create() navigator.credentials.get()
认证器动作 生成新密钥对 用已有私钥签名
返回给 RP 的内容 公钥、credentialId、attestationObject authenticatorData、clientDataJSON、signature
RP 侧动作 验证 attestation,存储公钥凭证 用公钥验签,检查挑战值和签名计数器
用户验证 通常需要 UV 或 UP 根据策略要求 UV 或 UP

五、认证器数据中的关键字段

认证器返回的 authenticatorData 是一段二进制结构,RP 在验签时需要解析它。其中几个字段值得特别关注:

  • rpIdHash:RP ID 的 SHA-256 哈希,用于确认该断言确实是为当前 RP 生成的,防止跨 RP 攻击。
  • flags:一个字节的位标志,包含 UP(用户存在)、UV(用户验证)、AT(是否包含 attested credential data)、ED(是否包含扩展数据)等。
  • signCount:签名计数器。认证器每次签名后递增。如果 RP 发现某次认证的 signCount 没有增长,可能意味着认证器被克隆,应触发安全告警。不过部分平台认证器不保证递增,需结合具体认证器类型判断。
  • AAGUID:认证器全局唯一标识,用于识别认证器型号和厂商,在注册阶段返回。
实践提示:在开发调试时,可以先把 authenticatorData 按字节解析出来,逐字段打印 rpIdHash、flags 和 signCount,再与 RP 侧保存的值比对。这样能快速定位是挑战值不匹配、RP ID 不一致,还是签名计数器异常。

六、术语之间的关系总结

把上述概念串起来:用户通过认证器完成身份验证;认证器在注册时生成密钥对,私钥留在本地,公钥凭证交给依赖方保存;依赖方在认证时生成挑战值,客户端通过 WebAuthn 和 CTAP2 把挑战值送到认证器;认证器用私钥签名后回传,依赖方用公钥验签。整个过程中,私钥从未离开认证器,服务器也从未接触过任何可被直接盗用的秘密。

理解这套架构后,后续讨论“平台认证器与跨平台认证器的区别”“attestation 到底要不要验证”“resident key 和 discoverable credential 是什么”时,就能把它们放回正确的位置,而不是孤立地记忆名词。