一个站点希望用 passkey 登录后,再让用户提交合成 HTTPS 订单。屏幕上似乎只点了一次“用设备解锁”,服务器却要判断三件不同的事:这是先前注册的凭据吗?这个凭据是在本网站发起、且回应的是这次挑战吗?认证器是否按站点政策完成了用户验证?单说“用了非对称密码学”无法回答其中任何一道题。

凭据私钥在哪,服务器保存什么

WebAuthn 的注册阶段,认证器针对一个 relying party(RP,依赖方,例如 orders.test)创建凭据,私钥由认证器或平台的凭据管理机制持有;服务端保存凭据标识与公钥,并把它关联到允许登录的账户及 RP。注册的来源、证明政策、哪些公钥可以保存,本身需要单独审查。下面实验预置一条合成注册记录,因而只能讨论登录断言,不能顺带宣称已验证真实注册或 attestation。

登录时服务端先生成一次性、不可预测的 challenge,浏览器提供站点 origin 信息并请求认证器生成断言。认证器对自身的 authenticatorData 加上浏览器产生的 clientDataJSON 字节的 SHA-256 摘要签名:

1
2
3
4
5
6
7
8
服务端:生成 challenge,保存期望值;查 credential ID → 受信公钥 / 账户
浏览器:在 https://orders.test 调用凭据,组装 clientDataJSON
认证器:生成包含 SHA256(RP ID) / flags / signCount 的 authenticatorData
sign(私钥, authenticatorData || SHA256(clientDataJSON)) → signature

服务端验断言:type=webauthn.get;challenge=本次值;origin=预期站点;
rpIdHash=SHA256(orders.test);UP=1;按要求 UV=1;
用该账户记录的公钥验 signature;消费本次 challenge

其中 UP(用户在场)与 UV(用户验证)是不同信号。前者不是“人脸已经核对”的缩写,后者由认证器实际执行的本地验证机制产生,服务端依据签名覆盖的 flags 和自身政策决定是否接受。网站不用得到生物识别原始数据,也不能只凭自己的 JavaScript 设置 UV 位就让真实认证器证明用户已验证。对于可以备份、在多设备同步的 passkey,哪些设备能使用凭据、如何恢复和撤销,还要另外分析;“passkey 等于不可导出硬件私钥”并不普遍成立。

正确签名还要绑定正确站点

没有 challenge,捕获的旧断言可能在另一次登录被提交;签名不能自动说明它是这次请求。没有 origin 检查,浏览器在冒牌站点发起的操作也可能被错误当成目标站点发起;没有 RP ID hash 检查,凭据签名未被服务器正确绑定到预期 relying party。浏览器本身会执行来源与 RP 约束,但服务器仍需要独立验证相应值。若服务端只调用“验签函数”而不核对签名中承载的上下文字节,程序验证到的结论比设计者以为的窄。

这是一种站点绑定的登录机制,不是钱包自动给任何链签交易的承诺。若登录后页面再经 HTTPS RPC 提交测试链交易,浏览器与钱包仍应分别确认 RPC 服务、要签的交易字节、钱包授权范围与账本状态。WebAuthn 凭据公钥证明登录身份;不是那笔交易的链上账户公钥,也不能推出订单已付款或交付。

教学实验与没有运行的现实环节

examples/cryptography/25_webauthn_assertion.mjs 使用 Node 22 的成熟 Ed25519 实现生成临时密钥,并真正对 authenticatorData || SHA256(clientDataJSON) 签名。它构造合成 orders.test RP ID、https://orders.test origin、随机 challenge 与基础断言字节,服务端持有“预注册”公钥作为可信输入。正例只接受一次,原样第二次因本次内存挑战消费状态而拒;改 origin 或 RP ID,即便用正确私钥重新签仍拒;按测试的“UV 必需”政策,缺 UV 位也拒;用另一凭据私钥签则公钥验不过。

先按仓库内 examples/cryptography/README.md 完成环境准备;以下命令从仓库根目录执行。

1
2
node examples/cryptography/25_webauthn_assertion.mjs
node --test examples/cryptography/25_webauthn_assertion.test.mjs

2026-10-06 UTC 两条命令退出码 0、3 项测试通过。这些是真实密码学原语上的合成断言,不是真实浏览器 WebAuthn 组件实验:没有调用浏览器、用户操作系统的凭据接口或物理认证器,没有让用户实际解锁或生物识别,也没有执行真实注册。脚本的 real_browser_or_user_verification_executed=false 专门防止把伪造的 UV 标志当成真实发生的用户验证。当前实验对 signCount 没做部署级克隆风险判定;W3C 规范提示计数器回退至多是风险信号,不是无争议的克隆证明。

两道带答案的练习

画图题: 标注浏览器页面、认证器、依赖方服务器三个框中,谁知道 origin、RP ID、私钥、服务端发出的 challenge、公钥以及实际执行用户验证的组件。拿掉服务器的 origin 检查和 challenge 消费状态各会失去什么?

可核对答案: 浏览器提供来源上下文,认证器持凭据私钥并负责真实 UP/UV 操作,服务器事先保存公钥和预期 RP/允许来源、生成并消费 challenge。省去 origin 检查将不能让服务端确定签名绑定的页面来源是否符合预期;省去已用 challenge 消费状态,旧的有效断言在仍被接受的挑战条件下可能重复使用。仅看签名返回 true 不会补上这些验证。

变更题: 保留正确私钥,但将 25_webauthn_assertion.mjs 正例生成语句改成 syntheticAssertion(privateKey, challenge, { origin: 'https://other.test' }),服务端常量 ORIGIN 保持不变;另一次运行移除 verifyAssertion 的 UV 必需判断。预期两条断言分别变化如何?真实用户是否因此“完成验证”?

可核对答案: 第一处密码学签名仍有效,但正例因来源与预期不符被拒,registered_public_key_accepts_one_assertion 断言失败;原先用于测试的错误 origin 负例仍被拒。第二处 missing_uv_bit_rejected_by_required_uv_policy 不成立,模拟服务器开始接受只有 UP 的断言;既没有浏览器、认证器,也没有真实用户操作,取消一个检查不能把模型 UV 位变成真实验证。

资料与导航