在当前信息安全领域,万能钥匙(Passkey)无疑是最重要的技术之一。它作为应对网络钓鱼攻击的唯一原则性解决方案,其地位类似于内存安全在解决内存损坏攻击中的根本性作用。

然而,在服务器端实现万能钥匙比使用密码哈希要复杂一些。这种复杂性部分源于不可避免的因素——密钥需要与浏览器交互以获得其抗钓鱼属性。好消息是,通过定义可互操作的密钥记录编码,可以更高效地抽象出部分工作量。
WebAuthn 规范将凭证记录定义为一个抽象概念,包含诸多组件,例如 type、id、publicKey、backupState、transports 以及其他标志。Google 建议使用一个包含 Credential ID 主键、public_key、backed_up 和 transports 列的数据库表。Adam Langley 在其精彩的 WebAuthn 之旅中也建议采用 cred_id 主键,以及独立的 public_key_spki 和 backed_up 列。
所有指南都推荐使用库来处理 WebAuthn 身份验证,但这会导致应用程序的数据库架构存在潜在的不可互操作问题。将整个身份验证流程和数据库交互全部交给库或框架,有时并不现实。
实际上,应用程序可以将可互操作、明确指定的密钥记录视为不透明字符串(类似密码哈希),从而构建一个中间抽象层。
c2sp.org/passkey-record 是一项规范提案,它借用了密码哈希竞争 (PHC) 字符串的语法,并在大部分工作中复用现有的验证器数据编码。一条记录看起来像这样:
$passkey-v1$
有效负载是身份验证器数据,即大多数凭证记录字段的 CTAP2 CBOR 编码,已由 WebAuthn 指定,并包含在 AuthenticatorAttestationResponse 的 JSON 编码中(这是 navigator.credentials.create() 的返回类型,即使未使用证明)。传输是唯一缺失的字段,它们存储为 PHC 参数。
然后,应用程序只需负责跟踪与用户账户关联的密钥记录——这对 Web 开发者来说并不陌生,与实现密码身份验证类似(区别在于每个账户可能有多个密钥)。这些不透明字符串可以传递给库,用于验证登录断言(或通过合适的 exceptedCredentials 生成注册请求)。
通过明确指定的可互操作存储格式,可以在保留凭证数据库的同时切换密钥库(甚至后端语言)。
您可能想要存储的其他字段
除了密钥记录,应用程序可能还需要存储元数据字段,例如用户自定义的昵称、创建时间和上次使用时间戳,以便提供友好的密钥管理 UI。这些都不需要 WebAuthn 库做特殊处理。
一个例外是备份状态标志。密钥会向服务器报告它们是否已备份(例如备份到 iCloud 钥匙串或 Google 账户),服务器可以利用这个信号向用户建议从账户中删除密码。该标志在登录时可能变化,而密钥记录是不可变的,因此必须单独存储并每次登录时更新。对于一般网站来说,这种逻辑可能有些过度——毕竟这些网站通常还会同时支持电子邮件密码重置。
潜在的加密/密钥 API
在这些密钥记录的基础上,可以起草一个潜在的加密/密钥无状态 Go 包 API。
注册流程为:
- 用登录(或以其他方式识别)的用户详细信息和任何现有的密钥记录调用
RelyingParty.NewRegistration - 将返回的 JSON 传递给
parseCreationOptionsFromJSON(),然后传递给navigator.credentials.create() - 将返回的 JSON 编码的
PublicKeyCredential传递给RelyingParty.Register - 将返回的密钥记录存储在数据库中
登录流程为:
- 生成登录页面时调用
RelyingParty.NewLogin - 将返回的请求存储在具有短 TTL 的键值缓存中,位于
RequestID(request)下,并将返回的 JSON 传递给parseRequestOptionsFromJSON(),然后传递给navigator.credentials.get() - 将返回的 JSON 编码的
PublicKeyCredential传递给Inspect,并使用返回的requestID从键值缓存中检索请求,使用返回的userID从数据库中检索密钥记录 - 将 JSON
PublicKeyCredential、请求和密钥记录传递给RelyingParty.Login
该应用程序负责:
- 将不透明、永久的、保护隐私的用户 ID 与每个用户关联;
- 存储与用户相关的密钥记录;以及
- 由
RelyingParty.NewLogin产生的缓存请求挑战。
该库提供可以直接传递给 parseCreationOptionsFromJSON() 和 parseRequestOptionsFromJSON() 的 JSON 值,并接受通过在 PublicKeyCredential 上调用 JSON.stringify() 返回的 JSON 值。
这是针对可发现的凭证流(即密钥,身份验证器存储并向服务器提供用户 ID)进行了优化,但 RelyingParty.NewLoginForUser 方法也可用于第二因素流或重新身份验证提示。该 API 适用于模态和条件 UI(自动填充)流。
有一些帮助程序可以从密钥记录(AAGUID、BackedUp)和 JSON 编码的 PublicKeyCredential(ResponseBackedUp)中提取信息。
目前尚未实施;在可能提出 Go 1.28 提案之前,希望获得有关密钥记录格式和 Go API 的反馈。
关于重复的凭证 ID
使用此存储模型不能做的一件事是确保不同的账户不会共享具有相同凭证 ID 的密钥,而规范规定您应该这样做。
进行该检查的原因是避免攻击:攻击者通过 ID 查找凭证,发现错误的公钥或用户 ID,因为攻击者故意通过自己的账户注入冲突的凭证 ID。
如果您一开始就没有凭证 ID 索引,这种攻击根本不可能发生!仅需要索引来减轻因索引的存在而引入的攻击。
登录尝试携带用户 ID,如果您用这个 ID 查找用户的密钥记录来验证登录,那么其他用户是否拥有相同凭证 ID 的密钥并不重要——就像两个用户共享密码并不重要一样。
不要让攻击者决定您的主键,这样就不会遭受主键冲突攻击。
图片
来自今年 CENTOPASSI 的更多内容(一项 GPS 追踪摩托车比赛,涉及精心策划、100 个坐标以及三天半时间内 1700 公里的二级公路)。这是从荒无人烟、仍然白雪皑皑的 Campo Imperatore 爬下来后,看到的蒙特堡 (AQ)。
