# Passkey 中文开发指南

> 基于 W3C WebAuthn Level 2（2021）的中文编译学习指南，非官方译文。现代 Passkey 补充已在对应章节标注。
> 英文原文：https://www.w3.org/TR/webauthn-2/
> 最后更新：2026-10-16

<a id="overview"></a>

# 从英文规范开始，学会 Passkey 开发

Passkey（通行密钥）让用户通过设备上的解锁方式登录网站。对开发者来说，核心变化是：**服务器保存公钥，登录时验证一次性挑战的签名**，而不是接收用户输入的密码。指纹、面容或设备 PIN 用于本地授权，不会作为 WebAuthn 响应发送给网站。

这份文档面向已经了解 HTTP、JavaScript 和基本登录流程的开发者。目标是读懂 WebAuthn 的安全边界，理解注册与登录的数据流，并通过一个可运行的示例把浏览器 API 和服务器验证连接起来。

## 这份中文文档与英文原文的关系

你提供的链接是 W3C 的 **Web Authentication Level 2**，发布于 **2021 年 4 月 8 日**。它是一份规范，涉及浏览器、认证器和网站服务器的行为；它并非一篇按现代 Passkey 产品体验组织的开发教程。

本书将原规范中与网站开发最相关的部分重新编排，提供**中文整理、关键语义译解、字段说明及新增代码讲解**。这是非官方的学习版本，不是逐条完整译本；原规范的算法细节、符合性条款和所有证明格式仍应查阅英文。正文中的“译解”保留原意，其他解释、示例、取舍建议属于本书补充。出处、修改说明和文档使用规则见[参考资料与版权说明](#references)。

截至本书核对日期 **2026 年 10 月 16 日**，WebAuthn Level 3 已于 **2026 年 8 月 25 日**成为 W3C Recommendation。下文以你指定的 Level 2 为基础；同步通行密钥、条件式界面和新的 JSON 辅助方法等现代内容，会明确标为补充，避免把后来的能力误认为 2021 年原文已有的要求。

- [你提供的原始英文链接](https://www.w3.org/TR/webauthn-2/?utm_source=chatgpt.com#sctn-usecase-authentication)
- [英文原文：WebAuthn Level 2 全文](https://www.w3.org/TR/webauthn-2/)
- [固定版本：2021 年 4 月 8 日 Recommendation](https://www.w3.org/TR/2021/REC-webauthn-2-20210408/)
- [现代补充：WebAuthn Level 3](https://www.w3.org/TR/webauthn-3/)

## 先理解三个层次

| 层次 | 解决的问题 | 你主要接触的内容 |
| --- | --- | --- |
| Passkey 用户体验 | 如何让用户创建、选择、使用通行密钥 | 注册入口、系统选择器、账户恢复、凭据管理 |
| WebAuthn | 网页如何请求创建凭据、如何获得认证断言 | `navigator.credentials.create()`、`navigator.credentials.get()` |
| 认证器与通信机制 | 私钥由谁保护、如何与客户端协作 | 平台认证器、安全密钥；外部认证器可能通过 CTAP 等机制通信 |

不要把“Passkey”理解为某个 JavaScript 库，也不要把“FIDO2”理解为一条独立的 HTTP 登录接口。WebAuthn 负责浏览器侧的标准接口和相关协议要求；你仍然要实现服务器端的挑战管理、响应验证和会话管理。

FIDO 对 Passkey 的说明包含同步型和设备绑定型凭据。是否能够同步、同步到哪些设备、如何恢复，是凭据提供者与平台能力的一部分；网站不能仅凭浏览器 `create()` 成功，就推断凭据一定已经备份到云端。[FIDO 官方 Passkey 介绍](https://fidoalliance.org/passkeys/)

## 原规范导读：两种 ceremony

“Ceremony”在这里译为“交互流程”：它不仅是一次密码学运算，还包括服务器请求、浏览器判断来源、认证器与用户交互以及服务器最终校验。

**§1 引言译解：**网站通过两种相关流程使用 WebAuthn。注册时，认证器为该网站创建公钥凭据，并把它关联到当前用户的账户；认证时，网站接收认证断言，以验证注册该凭据的用户参与并同意了本次操作。[英文原文 §1 Introduction](https://www.w3.org/TR/webauthn-2/#sctn-intro)

把它改写成开发任务：

1. 注册：先确认“给哪个账户添加凭据”，再请求生成密钥；验证响应后保存凭据 ID、公钥与账户的关系。
2. 登录：先发出新的随机挑战，再验证已有私钥对本次上下文的签名；验证全部通过后建立登录会话。

这里的“注册凭据”不等于“新建用户”。一个已有账户可以添加多个凭据；用户也可以先创建账户，再完成通行密钥注册。网站必须确定这两步之间的可信账户绑定，不能任由客户端在完成注册时提交另一个账户 ID。

## 建议学习路线

| 阶段 | 阅读内容 | 完成后应能解释 |
| --- | --- | --- |
| 建立模型 | [术语与安全边界](#concepts)、[注册与认证协议](#protocol) | RP ID、origin、UP/UV，以及谁持有私钥 |
| 接入浏览器 | [注册详解](#registration)、[认证详解](#authentication) | 创建与获取凭据所需字段，JSON 与二进制转换 |
| 接入服务器 | 服务端验证章节与[完整示例](#complete-example) | 为什么不能仅凭浏览器返回成功就登录 |
| 上线前检查 | 安全、恢复、测试与部署章节 | 挑战的一次性、域名绑定、账户恢复与真实设备验证 |
| 深入规范 | 原文 §6、§8、§9、§10 | 认证器数据结构、证明格式和扩展机制 |

如果你是在已有网站中增加登录方式，优先完成“已登录用户添加凭据 → 验证通行密钥登录 → 凭据管理与恢复”这条闭环。这样可以先解决账户绑定，再研究无用户名登录和自动填充等交互。

## 一个最小心智模型

假设网站为 `https://login.example.com`，用户是“小林”。

注册后，服务器保存类似以下信息：

```text
账户: user_42
用户句柄: 随机的、不含邮箱的字节序列
凭据 ID: 认证器生成的凭据标识
凭据公钥: 与该凭据对应的验证公钥
计数器等状态: 验证器返回并按策略维护
```

再次登录时，服务器生成随机挑战 `challenge_A`。浏览器请求认证器对绑定到该网站的凭据进行操作。认证器授权后产生签名；服务器使用之前保存的公钥，连同挑战、来源、RP ID 等一起验证。

如果攻击者复制上一轮响应，`challenge_A` 已失效，不能用于新一轮登录。如果攻击者搭建另一个域名的钓鱼网站，标准客户端会根据调用来源限制它可使用的 RP ID。**防重放依赖服务器正确管理挑战，防钓鱼依赖完整执行来源与 RP 绑定校验**，两者都不能靠前端 UI 替代。

## Passkey 没有替你完成什么

你仍然需要决定“谁能添加或删除一个凭据”“丢失所有凭据后如何恢复”“成功认证后如何保护会话”。通行密钥减少密码泄露、复用和钓鱼风险，但恶意脚本、会话窃取、薄弱的账户恢复流程和错误的服务器验证仍可能让账户被接管。

静态文档可以发布到 Cloudflare Pages，浏览器也能在 HTTPS 页面调用 WebAuthn；**真正的多用户登录还需要可信服务器或边缘函数、持久化存储和安全会话**。本书中的学习网站与可运行示例会清楚区分这两个层次。

## 读原文时的定位方法

W3C 原文会用 MUST、SHOULD、MAY 区分要求强度。可分别理解为“必须”“应当”“可以”，不要把 SHOULD 当成可随意忽略的备注。原文也明确标注某些部分为非规范性说明；用例和示例帮助理解，实现验证规则要回到规范性算法。

网站开发者最值得反复对照的是[§7 WebAuthn Relying Party Operations](https://www.w3.org/TR/webauthn-2/#sctn-rp-operations)，而不是照抄 §1 的演示代码。原文给出的[§1.1 阅读路线](https://www.w3.org/TR/webauthn-2/#sctn-spec-roadmap)同样强调了这一点。

## 学习检查

- `create()` 成功后，为什么服务器还必须验证响应？
- 服务器拥有公钥之后，为什么仍需每次生成不同的 challenge？
- 为什么“给已有账户增加凭据”通常比从零设计无密码开户更适合作为第一步？

带着这三个问题进入下一章。它们对应的是信任边界、重放防护和账户绑定，也是最容易被“几行前端代码”掩盖的核心问题。


---

<a id="concepts"></a>

# 术语与安全边界

学习 WebAuthn，先把“网站、浏览器、认证器、账户、凭据”区分开。它们承担不同职责；错误地把某一方返回的数据直接当成可信事实，往往比加密算法选错更容易造成漏洞。

[英文原文 §4 Terminology](https://www.w3.org/TR/webauthn-2/#sctn-terminology) · [§6 WebAuthn Authenticator Model](https://www.w3.org/TR/webauthn-2/#sctn-authenticator-model)

## 参与方：谁信任谁

| 英文术语 | 本书译法 | 具体职责 |
| --- | --- | --- |
| Relying Party / RP | 依赖方，通常就是你的网站 | 产生请求，保存公钥，验证响应，管理账户和会话 |
| WebAuthn Client / User Agent | WebAuthn 客户端 / 用户代理 | 通常由浏览器与系统协作；检查调用来源、协调认证器、组织客户端数据 |
| Authenticator | 认证器 | 创建或使用凭据密钥，执行用户在场 / 验证操作，产生响应 |
| User | 用户 | 选择账户、授权创建或使用凭据 |
| Credential | 凭据 | 面向某个 RP 的认证材料；不能简单等同于一个用户账户 |
| Ceremony | 交互流程 | 由多方共同完成的注册或认证过程 |

网页脚本不能读取凭据私钥。网站收到的是凭据标识、公钥相关材料、签名及上下文数据。生物识别通常在认证器本地执行；网站只获得 UP/UV 等结果标志，不获得指纹图像、人脸模型或设备 PIN。[英文原文 §14.3 本地生物识别](https://www.w3.org/TR/webauthn-2/#sctn-biometric-privacy)

“不把私钥发给网站”并不等于“私钥永远只存在于一块硬件里”。现代同步型通行密钥可能由提供者在受保护的同步体系中同步；设备绑定型凭据有不同的保护与恢复模型。网站需要依据实际政策和验证结果判断保证程度，不能从一个指纹弹窗推断密钥一定不可导出。

## origin 与 RP ID：一个精确来源，一个凭据作用域

`origin` 是来源，通常包含**协议、主机与端口**；例如 `https://login.example.com:8443`。它不包含路径。

`RP ID` 是凭据的作用域标识，采用域名形式，**没有协议、端口或路径**。对于 Level 2 的常规 Web 场景，RP ID 必须是当前有效域名本身，或该域名的可注册域后缀；不能随意指定别人的域名，也不能使用公共后缀。[英文原文：RP ID 定义](https://www.w3.org/TR/webauthn-2/#relying-party-identifier)

| 页面 origin | 请求的 RP ID | 是否符合常规域名关系 |
| --- | --- | --- |
| `https://login.example.com:8443` | `login.example.com` | 是，精确主机 |
| `https://login.example.com:8443` | `example.com` | 是，可注册父域 |
| `https://login.example.com:8443` | `m.login.example.com` | 否，是子域而非父域 |
| `https://login.example.com:8443` | `com` | 否，是公共后缀 |
| `https://evil.example.net` | `example.com` | 否，域名不匹配 |

浏览器的 RP ID 检查和服务器的 origin 检查解决不同问题。即便 `login.example.com` 和 `admin.example.com` 都能在域名规则上使用 `example.com`，服务器仍必须按本系统允许的 origin 精确验证。不要用字符串包含、模糊后缀匹配或接受所有 `*.example.com` 来代替明确的来源策略。

**域名选择是长期设计决策。**以 `example.com` 注册的凭据，不会因为你把新网站放到 `example.net` 就自动迁移。测试站点、Pages 预览 URL、生产域名应有明确边界；正式接入前应先确定稳定的生产 RP ID。[英文原文 §7.1 注册验证](https://www.w3.org/TR/webauthn-2/#sctn-registering-a-new-credential)

## 三种 ID，不要混用

| 字段 | 谁生成 | 用途 | 常见错误 |
| --- | --- | --- | --- |
| `user.id` / user handle | RP 服务器 | 用不透明字节稳定标识本网站账户 | 使用邮箱、昵称或每次都变化的随机值 |
| credential ID / `rawId` | 认证器 | 找到某个具体凭据及其公钥 | 把它当成秘密，或当成整个账户的唯一凭据 |
| challenge | RP 服务器 | 绑定并区分本次操作，防止重放 | 写成固定值、由客户端决定、使用后不失效 |

用户句柄在创建时必须是 1～64 字节；建议给每个账户分配稳定的随机值，不包含用户名或邮箱。一个账户的多个凭据通常使用同一个 user handle。`user.name` 和 `user.displayName` 是展示字段，可改变，也可能重复；认证与授权不能依据显示名称决定。[英文原文 §5.4.3 用户参数](https://www.w3.org/TR/webauthn-2/#dictdef-publickeycredentialuserentity)

credential ID 是不透明字节。网站应原样保存，不解析内部格式，不根据长度猜设备厂商。一个账户允许多个 credential ID，例如电脑内置认证器和备用安全密钥；一个 credential ID 对应的账户关系应由服务器保存并强制校验。

## challenge：公开，但必须不可预测且只用一次

challenge 不是密码，不需要向客户端保密；它的价值在于攻击者无法事先预测并重复使用。服务器使用密码学安全随机数生成器产生，记录预期值、操作类型、关联账户或会话和过期时间；完成验证时比对并消费。

原规范建议挑战至少 16 字节；本书示例采用 32 字节随机值。长度只是基本条件，`Math.random()` 拼成的字符串、时间戳和递增序号都不能替代安全随机数。`timeout` 只影响客户端交互，不会替服务器使挑战失效。[英文原文 §13.4.3 Cryptographic Challenges](https://www.w3.org/TR/webauthn-2/#sctn-cryptographic-challenges)

```js
// 服务器思路示意；不是可接受客户端自报 challenge 的接口。
const bytes = crypto.getRandomValues(new Uint8Array(32));
// 将 bytes 对应的挑战与操作、会话、截止时间保存在服务器。
// 验证响应时读取服务器记录，而非从提交内容中建立“预期值”。
```

## UP 与 UV：在场和验证不是同一个保证

| 标志 | 含义 | 典型操作 | 网站能否据此推断身份细节 |
| --- | --- | --- | --- |
| UP：User Presence | 检测到用户在场并参与 | 触碰安全密钥按钮等 | 不能证明触碰者是哪位用户 |
| UV：User Verification | 认证器执行了本地用户验证 | 设备 PIN、指纹、面容等 | 不能由此知道使用了哪种具体生物特征 |

`userVerification: "required"` 表示本次操作必须执行 UV，服务器也必须检查响应的 UV 位。`"preferred"` 表示希望执行，但无法执行时不一定失败；`"discouraged"` 表示不要求 UV，不是强制禁止认证器执行。不要把 `preferred` 当成“总是要求指纹”。[英文原文 §5.8.6 UV 需求](https://www.w3.org/TR/webauthn-2/#enumdef-userverificationrequirement)

要求无密码、单步骤登录时，本书示例选择 `required` 作为学习策略。更广的兼容性策略需要结合风险、是否还有其他因素及用户群体确定。无论选什么值，服务器必须按自己的预期验证，不能信任客户端声称“已经验证”。

## 认证器类型与凭据类型是两条轴

平台认证器（platform）通常集成在当前设备中；漫游认证器（roaming / cross-platform）可以用于不同设备，例如外接安全密钥。这描述的是认证器与客户端的附着关系。

可发现凭据（discoverable credential）允许客户端在服务器未提前列出凭据 ID 时找到可用于该 RP 的账户；不可发现凭据需要服务器在 `allowCredentials` 中提供 ID。旧名称 resident / non-resident 仍留在字段中，如 `residentKey`。这描述的是凭据发现能力。[英文原文 §6.2 认证器分类](https://www.w3.org/TR/webauthn-2/#sctn-authenticator-taxonomy)

因此，“平台 = 可发现”“安全密钥 = 不可发现”“可发现 = 已同步到云端”都不是成立的等式。现代通行密钥学习中应分别检查：能否发现、是否执行 UV、是否允许备份、是否已经备份、认证器采用何种保护方式。

## attestation 与 assertion

`attestation` 译为“证明”，主要用于注册，可能为认证器来源或属性提供可验证信息。`assertion` 译为“认证断言”，主要用于登录，证明持有该凭据私钥的一方为本次挑战进行了操作。

普通消费类应用通常不需要识别硬件厂商，可采用 `attestation: "none"` 作为起点。这不代表跳过注册响应验证，也不代表公钥不需要解析；服务器仍然要验证挑战、来源、RP ID、标志及凭据结构。需要企业设备合规或特定硬件保证时，再设计证明信任策略和元数据维护。[英文原文 §6.5 Attestation](https://www.w3.org/TR/webauthn-2/#sctn-attestation)

## 学习检查

1. 指纹验证成功，网站究竟获得了什么信息？
2. `user.name` 从“小林”改成邮箱后，为什么不应该影响已有凭据与账户的绑定？
3. RP ID 正确时，为什么仍必须验证完整 origin？
4. `allowCredentials` 为空，会允许攻击者不提供签名直接登录吗？

最后一题的答案是否定的。它改变的是“如何选择凭据”，服务器仍然必须找到已登记的公钥并完成全部认证验证；下一章会把这条信任链串起来。


---

<a id="protocol"></a>

# 注册与认证：把信任链串起来

WebAuthn 的浏览器调用各只有一个主要入口，但一次安全登录需要服务器、客户端、认证器和用户共同完成。理解每一步携带什么、验证什么，才能判断代码是否真正实现了认证。

[英文原文 §1.2 用例](https://www.w3.org/TR/webauthn-2/#sctn-use-cases) · [§1.3 API 示例](https://www.w3.org/TR/webauthn-2/#sctn-sample-scenarios) · [§7 RP 操作](https://www.w3.org/TR/webauthn-2/#sctn-rp-operations)

## 原文重点：你链接的登录场景

你提供的锚点是 [§1.2.2 Authentication](https://www.w3.org/TR/webauthn-2/#sctn-usecase-authentication)。下面是该**2021 年用例的中文译解**，用来理解电脑与手机协作的角色，不应照搬为现代用户操作说明：

1. 用户在电脑上通过蓝牙与手机配对，打开 `example.com` 并发起登录；浏览器提示到手机完成操作。
2. 手机显示登录该网站的通知；用户打开通知，从账户列表中选择身份，再通过已配置的 PIN、生物识别等方式授权。
3. 电脑上的网站显示所选账户已登录，并进入登录后的页面。

**现代补充：**今天的跨设备通行密钥体验可能由浏览器显示二维码并通过 hybrid 等机制协调。开发者应交给系统界面引导，不应要求所有用户先手工完成传统蓝牙配对。设备、浏览器和凭据提供者能力不同，也可能直接使用已同步到当前设备的通行密钥。[Level 3 消费者多设备用例](https://www.w3.org/TR/webauthn-3/#sctn-usecase-consumer-mdc)

这段用例说明了一个重要事实：执行网页的设备与使用私钥的设备可以不同。对 RP 服务器来说，最终仍然要验证同一套带上下文的响应，不能因为界面看起来像“手机确认”就降低验证要求。

## 注册：从已有账户到一个新公钥

```text
用户           RP 服务器              浏览器 / 系统             认证器
 |  已确认账户     |                       |                       |
 |--------------->|                       |                       |
 |                | 生成并保存 challenge  |                       |
 |                |----注册选项---------->|                       |
 |                |                       | create({ publicKey }) |
 |                |                       |----请求创建---------->|
 |<--------------------------显示账户 / 请求本地授权---------------|
 |---------------------------同意并完成 UV----------------------->|
 |                |                       |<---凭据与证明数据------|
 |                |<---注册响应 JSON-------|                       |
 |                | 验证并保存公钥 / ID    |                       |
 |<----添加成功----|                       |                       |
```

注册选项包括 RP 标识、用户句柄、随机挑战、支持的算法和认证器选择要求。浏览器会检查安全上下文和 RP ID 关系，组织客户端数据；认证器创建凭据并返回认证器数据及相关证明材料。

服务器收到响应后不能直接保存客户端随便提交的“公钥字符串”。它要依据[§7.1 注册算法](https://www.w3.org/TR/webauthn-2/#sctn-registering-a-new-credential)验证客户端数据、认证器数据、证明及本地策略，然后将验证得到的凭据公钥绑定到服务器早已确定的账户。

对不请求设备证明的普通应用，`attestation: "none"` 允许避免复杂证明信任链；这不会免除来源、挑战、RP ID 或 UV 检查。注册是否具有特定硬件来源保证，与“新公钥已安全绑定到哪个账户”是相关但不同的判断。

## 认证：用已保存公钥验证新挑战

```text
用户           RP 服务器              浏览器 / 系统             认证器
 |  点击登录       |                       |                       |
 |--------------->|                       |                       |
 |                | 生成新的 challenge    |                       |
 |                |----认证选项---------->|                       |
 |                |                       | get({ publicKey })    |
 |                |                       |----请求断言---------->|
 |<--------------------------选择账户 / 完成本地验证---------------|
 |                |                       |<---签名及上下文--------|
 |                |<---认证响应 JSON-------|                       |
 |                | 查公钥并完成全部验证   |                       |
 |                | 消费 challenge / 建会话|                       |
 |<----登录成功----|                       |                       |
```

服务器可以先让用户输入用户名，再下发该账户的 `allowCredentials`；也可以省略列表，依靠可发现凭据让用户在系统界面选择账户。二者的差别是凭据发现和账户定位方式，不改变签名与上下文校验的必要性。[英文原文 §7.2 认证算法](https://www.w3.org/TR/webauthn-2/#sctn-verifying-assertion)

## 签名到底覆盖什么

认证时，签名的核心输入为：

```text
authenticatorData || SHA-256(clientDataJSON)
```

`||` 表示字节拼接。`clientDataJSON` 是浏览器提供的原始 UTF-8 JSON 字节，其中包含操作类型、challenge、origin 等。`authenticatorData` 包含 RP ID 的哈希、UP/UV 标志、签名计数器，以及可能存在的扩展数据。[英文原文 §6.1 Authenticator Data](https://www.w3.org/TR/webauthn-2/#sctn-authenticator-data) · [§5.8.1 客户端数据](https://www.w3.org/TR/webauthn-2/#dictionary-client-data)

服务端必须对**收到的原始 `clientDataJSON` 字节**做哈希。先解析为对象、再 `JSON.stringify()` 会改变空白、属性顺序等，可能得到不同字节。解析用于检查字段，原始字节用于签名输入，两项工作都需要。

公钥验证通过，只能说明签名与该输入相符；如果服务器没检查其中的挑战或 origin，攻击者仍可能把不属于当前操作的有效签名提交过来。因此，“调用验签函数返回 true”不等于“完整认证通过”。

## 密码登录与 WebAuthn 的差别

| 维度 | 常见密码登录 | WebAuthn 登录 |
| --- | --- | --- |
| 用户侧认证材料 | 需记忆或由密码管理器填写的共享秘密 | 由认证器管理的凭据私钥 |
| 服务器主要保存 | 加盐密码哈希及参数 | 凭据 ID、公钥、账户绑定与状态 |
| 每次请求的核心证明 | 用户提交密码，服务器检查 | 对新挑战与上下文的签名 |
| 假站风险 | 用户可能把密码交给假站 | 标准客户端限制凭据的 RP 作用域 |
| 账户恢复 | 常见邮件 / 短信 / 人工流程 | 仍需设计；可结合备用凭据与恢复机制 |
| 服务器验证难点 | 密码哈希、限速、泄露防护 | 挑战生命周期、来源 / RP 绑定、签名及标志验证 |

公钥被读取不等于私钥泄露，因此服务器凭据库泄露的后果与密码哈希库不同；但账户信息、凭据 ID 和元数据仍有隐私与攻击价值，不能因此放弃访问控制或审计。

## 首次开户、添加凭据、重新认证

这三个业务流程都可能调用 `create()` 或 `get()`，但授权条件不同：

- **首次开户：**先确定产品如何分配账户和验证联系方式；WebAuthn 不会自动证明用户拥有某个邮箱。
- **添加凭据：**必须基于可信账户上下文，例如已有登录会话，并视风险要求近期重新认证；防止被盗会话轻易增加攻击者凭据。
- **敏感操作重新认证：**使用新的 challenge，明确绑定当前账户和操作；成功后授予有限时间或一次性的操作权限。

原文的[§1.2.3 新设备注册用例](https://www.w3.org/TR/webauthn-2/#sctn-usecase-new-device-registration)展示了先用已有安全密钥登录，再添加新设备平台认证器的思路。它也说明一个账户允许多凭据，对设备更换和恢复很有价值。

## 浏览器响应与服务器响应要分开

浏览器弹出成功动画，仅表示本地凭据操作完成；服务器可能因 challenge 过期、origin 不符或账户绑定错误而拒绝。前端应等待验证接口确认后再显示“登录成功”。

同样，`NotAllowedError` 可能涵盖用户取消、超时或无合适凭据等多种情况。页面应提供“未完成本次操作，可重试或选择其他方式”的正常反馈，而不是断言用户没有通行密钥或密码错误。

## 学习检查

尝试分别说出注册和认证时服务器“发什么、记什么、验什么、存什么”。如果回答里缺少服务器保存的预期 challenge，或者只提到公钥验签，那么信任链还没有闭合。接下来的两章将把请求与返回字段逐一对应到这条链上。


---

<a id="registration"></a>

# 注册详解：安全地绑定一把新公钥

注册的目标是：让认证器创建一个面向本网站的凭据，然后由服务器验证并绑定到正确账户。**`navigator.credentials.create()` 只完成浏览器侧的凭据创建；服务器验证成功，才算业务上的添加完成。**

[英文原文 §5.4 创建选项](https://www.w3.org/TR/webauthn-2/#dictionary-makecredentialoptions) · [§5.2.1 注册响应](https://www.w3.org/TR/webauthn-2/#iface-authenticatorattestationresponse) · [§7.1 服务端注册算法](https://www.w3.org/TR/webauthn-2/#sctn-registering-a-new-credential)

## 开始之前：先确定注册权限

对于已有账户，注册接口应从可信登录会话读取账户 ID。不要接收一个客户端可随意修改的 `userId`，然后给这个账户添加公钥。敏感账户还应要求近期重新认证，并为成功添加或删除凭据生成审计记录。

服务器先生成随机 challenge，连同当前账户、注册操作和过期时间暂存。请求返回的选项和稍后收到的响应必须属于同一次流程；验证时消费挑战，防止重复提交、并发竞态和跨账户绑定。

## 创建选项逐项理解

| 字段 | 类型 / 例子 | 它控制什么 | 应注意什么 |
| --- | --- | --- | --- |
| `challenge` | 浏览器中为字节；JSON 中常用 Base64URL | 绑定本次注册请求 | 由服务器安全随机生成并保存预期值 |
| `rp.id` | `example.com` | 新凭据的 RP 作用域 | 不能带协议 / 端口 / 路径；应长期稳定 |
| `rp.name` | `示例学习站` | Level 2 中的 RP 显示名称 | 展示字段，不能作为安全身份 |
| `user.id` | 1～64 字节 | 账户的稳定 user handle | 不包含邮箱等个人身份信息 |
| `user.name` | `xiaolin@example.com` | 供用户识别的账户名 | 不是认证授权依据；考虑展示隐私 |
| `user.displayName` | `小林` | 友好的显示名称 | 可以与别的账户重复 |
| `pubKeyCredParams` | `[{type:"public-key",alg:-7}]` | RP 接受的公钥算法，按偏好排列 | 服务端验证器必须实际支持选中的算法 |
| `excludeCredentials` | 已绑定凭据的描述列表 | 避免重复创建已有凭据 | ID 解码为字节；服务器仍需检查凭据唯一性 |
| `authenticatorSelection` | 选择条件对象 | 可发现性、UV、附着类型要求 | 需求过严可能排除用户可用的认证器 |
| `attestation` | 通常从 `"none"` 开始 | 是否请求认证器证明 | 不等于是否验证注册响应 |
| `timeout` | 毫秒，如 `60000` | 客户端等待时间提示 | 浏览器可调整；服务端另设过期策略 |
| `extensions` | 如 `{credProps:true}` | 请求可选扩展 | 可能被忽略，不能假定一定有输出 |

其中 `-7` 表示 ES256，`-257` 表示 RS256。这些是 COSE 算法标识，不是任意数字；不要把它们与证书签名算法或 HTTP 层 TLS 算法混为一谈。[英文原文 §5.8.5 算法标识](https://www.w3.org/TR/webauthn-2/#typedefdef-cosealgorithmidentifier)

## 为通行密钥选择创建策略

本书学习示例采用以下策略，适合练习可发现凭据与本地用户验证：

```js
// 这是服务器产生创建选项时采用的策略片段。
const registrationPolicy = {
  authenticatorSelection: {
    residentKey: "required",
    requireResidentKey: true,
    userVerification: "required",
  },
  attestation: "none",
  extensions: { credProps: true },
};
```

`residentKey: "required"` 要求创建可发现凭据；无法满足时应失败。`requireResidentKey: true` 用于兼容较旧实现，保持与 `residentKey` 的要求一致。`userVerification: "required"` 要求完成本地用户验证，后端也必须执行对应检查。[英文原文 §5.4.4 选择条件](https://www.w3.org/TR/webauthn-2/#dictdef-authenticatorselectioncriteria)

这里没有强制 `authenticatorAttachment: "platform"`，以便允许浏览器呈现适合的认证器选择。设置 `platform` 会限制为平台认证器，并不能保证“同步到所有设备”；设置 `cross-platform` 也不等于“必须是 USB”。选择附着类型应服务于业务要求，不应仅为了让演示弹出某一种系统窗口。

`residentKey: "preferred"` 可以扩大兼容范围，但可能得到不可发现凭据。若采用它，产品必须准备先输入用户名等登录路径，不能保证所有注册结果都能进行无用户名登录。`credProps.rk` 输出可帮助了解创建结果，缺少输出也不能简单推断为 `false`。[英文原文 §10.4 Credential Properties](https://www.w3.org/TR/webauthn-2/#sctn-authenticator-credential-properties-extension)

## JSON 与二进制：最常见的接入错误

HTTP JSON 不直接表达 `ArrayBuffer`。通常约定将二进制字段编码为 Base64URL 字符串；浏览器收到后，把 `challenge`、`user.id`、`excludeCredentials[].id` 解码为字节，再调用 `create()`。

Base64URL 用 `-`、`_` 代替普通 Base64 的 `+`、`/`，通常省略末尾的 `=`。它是编码方式，不是加密。不要用 `TextEncoder.encode(base64urlString)` 解码：那会得到“编码字符串本身”的 UTF-8 字节。

以下辅助函数针对本文的无填充 Base64URL 约定；真实项目可使用经过验证的库。服务器仍须限制输入大小、验证编码与结构，不应把浏览器侧校验当成安全防线。

```js
function fromBase64url(value) {
  if (typeof value !== "string" ||
      !/^[A-Za-z0-9_-]*$/.test(value) || value.length % 4 === 1) {
    throw new TypeError("无效的 Base64URL 字符串");
  }
  const base64 = value.replace(/-/g, "+").replace(/_/g, "/");
  const padded = base64 + "=".repeat((4 - base64.length % 4) % 4);
  return Uint8Array.from(atob(padded), c => c.charCodeAt(0));
}

function toBase64url(value) {
  const bytes = ArrayBuffer.isView(value)
    ? new Uint8Array(value.buffer, value.byteOffset, value.byteLength)
    : new Uint8Array(value);
  let binary = "";
  for (const byte of bytes) binary += String.fromCharCode(byte);
  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}

async function postJSON(path, body = {}) {
  const response = await fetch(path, {
    method: "POST",
    credentials: "same-origin",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!response.ok) throw new Error("操作失败，请重试");
  return response.json();
}
```

## 原生 API 注册示例

这段是浏览器端集成示例，假设后端已实现同源的两个接口：`/api/registration/options` 直接返回 JSON 格式创建选项；`/api/registration/verify` 验证并保存凭据，通过服务端会话绑定账户和挑战。可运行的后端与浏览器源码见[完整示例](#complete-example)。

```js
async function registerPasskey() {
  if (!window.isSecureContext || !window.PublicKeyCredential) {
    throw new Error("当前环境不支持 WebAuthn 安全调用");
  }

  const options = await postJSON("/api/registration/options");
  const publicKey = {
    ...options,
    challenge: fromBase64url(options.challenge),
    user: { ...options.user, id: fromBase64url(options.user.id) },
    excludeCredentials: (options.excludeCredentials ?? []).map(item => ({
      ...item,
      id: fromBase64url(item.id),
    })),
  };

  const credential = await navigator.credentials.create({ publicKey });
  if (!(credential instanceof PublicKeyCredential)) {
    throw new Error("未创建公钥凭据");
  }

  const response = credential.response;
  await postJSON("/api/registration/verify", {
    id: credential.id,
    rawId: toBase64url(credential.rawId),
    type: credential.type,
    clientExtensionResults: credential.getClientExtensionResults(),
    response: {
      clientDataJSON: toBase64url(response.clientDataJSON),
      attestationObject: toBase64url(response.attestationObject),
      transports: response.getTransports?.() ?? [],
    },
  });

  // 到这里才可向用户显示“通行密钥添加成功”。
}
```

普通界面应从清楚的“添加通行密钥”按钮触发，并在操作期间管理按钮状态。失败后恢复操作入口，把取消和超时作为正常情况处理。不要在页面加载时不断弹出注册提示。

这个 `postJSON` 仅演示传输。服务器还需验证请求来源、会话和 CSRF 策略；设置 `Content-Type` 或依赖“按钮只能被本人点击”都不能替代服务端权限检查。若扩展包含额外二进制输出，通用序列化器还需要针对扩展处理；本文代码只使用输出为布尔值的 `credProps`。

## 服务器应保存什么

在完成规范校验之后，通常至少保存：账户 ID、稳定 user handle、credential ID、公钥字节及算法、签名计数器，以及可用于后续交互的 transport 提示。允许用户给凭据设置“办公室电脑”等备注，但不要把备注当作已验证的设备型号。

现代同步型凭据的备份状态与计数器处理有额外注意事项，应使用支持相应规范的验证器。不要只复制 Level 2 中某个字段判断片段，就断言所有现代通行密钥必须有严格递增计数器。

删除凭据时，服务器可以停止接受该 credential ID；这不等于删除了用户设备或同步提供者中的凭据。产品应说明这种区别，并避免删除账户最后一个可用登录 / 恢复途径后让用户被锁在账户外。

## 排错与自测

| 现象 | 优先检查 |
| --- | --- |
| 调用即报 `TypeError` | `user.id` 长度、必填字段、Base64URL 是否解码为字节 |
| `SecurityError` | HTTPS、RP ID 是否允许、iframe / 权限策略 |
| `NotAllowedError` | 用户取消、超时、无法满足 residentKey / UV 等要求 |
| 浏览器成功、服务器失败 | challenge 是否属于同一会话、origin / RP ID 配置、编码是否改变 |
| 相同账户重复注册行为不符预期 | `excludeCredentials`、user handle 稳定性、认证器的凭据管理行为 |

最值得做的反向测试是：修改返回响应里的 challenge、换一个 origin、把注册结果提交给另一个账户会话。这些情况必须由服务器拒绝，而不仅仅是在正常路径上看到一次“添加成功”。


---

<a id="authentication"></a>

# 认证详解：从签名到可信登录会话

认证使用 `navigator.credentials.get()` 请求已有凭据产生断言。真正决定登录成功的是服务器：它必须找到已登记公钥，验证本次响应和预期上下文，再签发或更新登录会话。

[英文原文 §5.5 认证请求选项](https://www.w3.org/TR/webauthn-2/#dictionary-assertion-options) · [§5.2.2 认证响应](https://www.w3.org/TR/webauthn-2/#iface-authenticatorassertionresponse) · [§7.2 验证认证断言](https://www.w3.org/TR/webauthn-2/#sctn-verifying-assertion)

## 先选登录路径

### 路径 A：先确定用户名

用户先输入用户名或邮箱。服务器查出对应账户已登记的 credential ID，在 `allowCredentials` 中发送给浏览器。浏览器请求认证器使用这些凭据之一。

它适合兼容不可发现凭据，也适合需要先定位租户或账户的系统。服务器必须验证返回的凭据确实属于预期账户，且符合本次下发的允许列表，不能只按“签名有效”就接受任意账户的凭据。

查询接口还要考虑账户枚举：未知用户名与已知用户名在响应、错误消息、耗时和限速策略上的差异，可能暴露账户是否存在。不要在公开错误里直接显示“此邮箱存在，但尚未注册通行密钥”。[英文原文 §14.6.2 Username Enumeration](https://www.w3.org/TR/webauthn-2/#sctn-username-enumeration)

### 路径 B：先选择通行密钥

不提前输入用户名，服务器省略 `allowCredentials` 或发送空列表。客户端依靠可发现凭据，为该 RP 提供账户选择。此路径要求用户具有可发现凭据；普通不可发现凭据不会因列表为空而自动变得可发现。

服务器收到响应后，利用已登记的 credential ID 定位凭据与账户；还要验证 `userHandle` 与该凭据绑定的账户一致。此类未预先识别用户的流程应要求非空的有效 user handle，并将其与数据库中的稳定用户句柄比对，不能信任客户端额外附带的显示名称或账户 ID。[英文原文 §7.2 第 6 步的账户识别规则](https://www.w3.org/TR/webauthn-2/#sctn-verifying-assertion)

| 问题 | 先输入用户名 | 先选择通行密钥 |
| --- | --- | --- |
| `allowCredentials` | 当前账户的已登记凭据列表 | 省略或为空 |
| 是否支持不可发现凭据 | 支持 | 通常不适用 |
| 账户何时确定 | 发出断言请求前 | 验证断言时通过登记记录确定 |
| 核心额外检查 | 凭据属于预期账户和列表 | credential ID 与 user handle 一致地映射到已登记账户 |
| 主要体验取舍 | 多一步输入，定位明确 | 输入更少，依赖可发现凭据与账户选择界面 |

两种路径都要验证 challenge、origin、RP ID、UP/UV 和签名；不输入用户名只是移除了一个账户定位步骤，不会移除安全检查。

## 请求选项

| 字段 | 例子 | 注意事项 |
| --- | --- | --- |
| `challenge` | 服务器新生成的随机字节 | 与本次认证会话绑定、短时有效且只能使用一次 |
| `rpId` | `example.com` | 必须与凭据创建时 RP ID 一致；注意字段名与注册的 `rp.id` 不同 |
| `allowCredentials` | `{type,id,transports}` 列表 | ID 在调用前从 Base64URL 解码；transport 是交互提示 |
| `userVerification` | `"required"` | 服务端也必须检查 UV；`preferred` 无法提供必然 UV 的保证 |
| `timeout` | `60000` | 客户端提示，不能代替服务器过期时间 |
| `extensions` | 可选扩展输入 | 只请求理解且实际需要的扩展 |

`allowCredentials[].transports` 可来自注册时的 `getTransports()`，有助于客户端呈现合适选择。它不是服务器证明私钥在何处的可信证据，也不应被误用为授权条件。[英文原文 §5.8.3 凭据描述](https://www.w3.org/TR/webauthn-2/#dictdef-publickeycredentialdescriptor)

## 原生 API 登录示例

以下代码复用[注册章](#registration)中的 `fromBase64url()`、`toBase64url()` 和 `postJSON()`。假设 `/api/authentication/options` 直接返回 JSON 请求选项，服务器通过会话或等效机制保存预期挑战；`/api/authentication/verify` 只有在完整验证通过后才建立会话。

```js
async function signInWithPasskey() {
  if (!window.isSecureContext || !window.PublicKeyCredential) {
    throw new Error("当前环境不支持 WebAuthn 安全调用");
  }

  // 此处为可发现凭据登录；服务端不预先指定用户名。
  const options = await postJSON("/api/authentication/options");
  const publicKey = {
    ...options,
    challenge: fromBase64url(options.challenge),
  };
  if (options.allowCredentials !== undefined) {
    publicKey.allowCredentials = options.allowCredentials.map(item => ({
      ...item,
      id: fromBase64url(item.id),
    }));
  }

  const credential = await navigator.credentials.get({ publicKey });
  if (!(credential instanceof PublicKeyCredential)) {
    throw new Error("未获得公钥凭据响应");
  }

  const response = credential.response;
  const result = await postJSON("/api/authentication/verify", {
    id: credential.id,
    rawId: toBase64url(credential.rawId),
    type: credential.type,
    clientExtensionResults: credential.getClientExtensionResults(),
    response: {
      clientDataJSON: toBase64url(response.clientDataJSON),
      authenticatorData: toBase64url(response.authenticatorData),
      signature: toBase64url(response.signature),
      userHandle: response.userHandle === null
        ? null
        : toBase64url(response.userHandle),
    },
  });

  // 只有服务器确认通过后，才显示登录成功或跳转。
  return result;
}
```

`userHandle` 允许为 `null`，尤其在已预先识别账户的某些流程中。序列化时要保留 `null`，不要把它编码成字符串 `"null"`、空字符串或全零字节。服务端根据本次登录路径，按规范检查其是否必须存在以及是否匹配。

上面的接口名称与[完整示例](#complete-example)一致。认证之后的会话 Cookie 应由服务器设置，JavaScript 返回的 `{verified:true}` 本身不是受保护资源的访问凭证。

## 服务器验证清单：每项都有关联目的

以下是对 §7.2 的学习性归纳，生产代码应使用成熟验证器完成数据解析和密码学校验，并在其外层实现正确的业务状态管理：

1. 检查响应结构、类型、大小与编码，确认对应一个本服务器发起且尚未过期的认证流程。
2. 若曾下发非空 `allowCredentials`，确认返回 ID 在列表中；从数据库找到凭据、公钥及其账户关系。
3. 验证账户识别规则，包括预期账户归属和需要时的 `userHandle` 一致性。
4. 解析 `clientDataJSON`，要求类型是 `webauthn.get`；challenge 与服务器保存值一致；origin 属于精确允许列表；根据部署策略处理跨来源上下文。
5. 验证 `authenticatorData.rpIdHash` 等于预期 RP ID 的 SHA-256；检查 UP，以及策略要求时的 UV。
6. 处理请求的扩展结果，使用该凭据登记的公钥和算法，验证 `authenticatorData || SHA-256(clientDataJSON)` 上的签名。
7. 按计数器与现代凭据的备份能力策略处理风险及状态更新；原子消费挑战，完成必要记录，然后建立会话。

“原子消费”是为了防止两个并发请求使用同一挑战都通过。短有效期和服务端签名的 token 也不能自动保证一次性；若采用无状态挑战，需要另行解决重放窗口与消费状态。

## 关于签名计数器

`signCount` 可辅助发现某些凭据克隆或状态异常，但不能简单当成“必须从 1 开始、每次严格加一”的业务计数。Level 2 已允许不实现计数的认证器使用零；当新旧值均为零时，不能因此认定克隆。

对于支持计数的情况，异常可作为风险信号；是否拒绝、要求补充验证或记录风险，需要结合认证器行为和业务策略。现代同步型通行密钥还应结合 Level 3 的备份语义，避免机械套用旧式硬件密钥假设。[英文原文 §6.1.1 Signature Counter Considerations](https://www.w3.org/TR/webauthn-2/#sctn-sign-counter) · [Level 3 认证器数据](https://www.w3.org/TR/webauthn-3/#sctn-authenticator-data)

## 条件式界面：现代补充

条件式 mediation 可让通行密钥出现在登录输入框的自动填充选择中。它属于本书的现代补充，不是你提供的 Level 2 示例流程。通常使用带 `autocomplete="username webauthn"` 的输入框、可发现凭据，以及 `mediation: "conditional"` 的请求，并先检测能力。

```js
const canUseConditionalUI =
  window.PublicKeyCredential &&
  typeof PublicKeyCredential.isConditionalMediationAvailable === "function" &&
  await PublicKeyCredential.isConditionalMediationAvailable();
```

功能检测成功只表示当前环境报告支持此能力，不证明账户已有通行密钥，也不保证每一种认证器都支持。实际调用仍需服务器挑战、正确序列化和完整验证；保留普通按钮登录路径。[英文原文 Level 3 条件式 mediation](https://www.w3.org/TR/webauthn-3/#dom-publickeycredential-isconditionalmediationavailable)

## 取消、重试与错误提示

页面切换登录方式或用户离开时，可用 `AbortController` 取消尚未完成的浏览器请求；不要同时挂起多个相互竞争的 `get()`。取消之后，服务器仍要按自己的过期 / 消费策略处理挑战。[英文原文 §5.6 Abort Operations](https://www.w3.org/TR/webauthn-2/#sctn-abortoperation)

把“取消 / 超时”“验证失败”“不支持”分别记录便于排错，但对外提示应避免暴露账户是否存在、公钥细节或内部验证错误。重试应获取新的挑战，不要将旧响应再次提交。

## 学习检查

- 把上一次成功响应原样重放，服务器是否拒绝？
- 使用另一账户的有效凭据，是否会被错误登录到当前预期账户？
- 修改 origin 或 RP ID，签名或上下文校验是否会失败？
- 要求 UV 时，响应缺少 UV 位是否被拒绝？
- 在验证接口成功之前，页面是否就已经显示“已登录”？

通过正常路径仅说明功能能够工作；这些反向检查才能说明关键安全约束确实在服务器生效。


---

<a id="server-verification"></a>

# 服务端验证：安全判断发生在哪里

浏览器弹出系统窗口、用户完成指纹验证，都不代表你的网站已经确认登录。最终决定来自服务器：**使用已保存的公钥，验证本次响应，并确认它属于正确的操作与账号。**

英文对照：[§7.1 注册新凭据](https://www.w3.org/TR/webauthn-2/#sctn-registering-a-new-credential) · [§7.2 验证认证断言](https://www.w3.org/TR/webauthn-2/#sctn-verifying-assertion)。以下是按开发职责整理的中文释义与工程补充；不是替代原文的逐步规范算法。

## 把验证分成三层

| 层次 | 谁负责 | 本地示例如何处理 |
| --- | --- | --- |
| 请求上下文 | 应用 | 固定来源、会话 Cookie、操作类型、一次性 challenge 与有效期 |
| WebAuthn 结构与密码学 | 验证库 | 解析客户端数据、CBOR / COSE、RP hash、标志、证明与签名 |
| 账号授权与状态 | 应用 | 凭据归属、user handle、唯一性、计数更新、成功后建立会话 |

成熟验证库能减少密码学实现错误，但无法知道你的业务账号属于谁。因此，“库返回成功”仍不能代替账号绑定检查。

## 注册响应需要核对什么

按 [§7.1 原文](https://www.w3.org/TR/webauthn-2/#sctn-registering-a-new-credential)阅读时，重点追踪：

1. 客户端数据的类型是 `webauthn.create`；challenge、origin 与服务端预期一致。
2. 解码证明对象，验证 RP ID hash、UP；要求 UV 时必须检查 UV。
3. 公钥算法属于发出的算法集合；扩展结果按使用策略处理。
4. 按证明格式验证，并决定其信任结果是否符合 RP 策略。
5. 检查凭据 ID 的账号归属与唯一性，再保存公钥等结果。

本示例明确接受 `none` 证明策略，因此不要求设备厂商证书。它仍校验注册数据；同时也不声称能证明设备型号、私钥处于硬件内或用户的现实身份。

## 登录响应需要核对什么

[§7.2 原文](https://www.w3.org/TR/webauthn-2/#sctn-verifying-assertion)可按下面的顺序理解：

1. 根据凭据 ID 查找已登记的公钥，确认凭据属于目标用户；有允许列表时还要检查列表约束。
2. 无用户名登录需要有效的 `userHandle`，并确认它和凭据归属指向同一账号。
3. 检查类型 `webauthn.get`、challenge、origin、RP ID hash、UP 与所要求的 UV；处理扩展。
4. 验证 `authenticatorData || SHA-256(clientDataJSON)` 的签名，评估计数器。
5. 只有全部通过后，更新状态并建立登录会话。

`||` 在这里表示**二进制拼接**，不是 JavaScript 的逻辑或。`clientDataJSON` 要使用响应里的原始字节计算哈希，不能先解析 JSON 再重新序列化。

## 对照可运行代码

[配套服务端源文件](https://passkey-dev-cn.pages.dev/downloads/example/server.js)使用以下调用进行登录验证；API 已按锁定版本 `@simplewebauthn/server 14.0.3`核对：

```js
const result = await verifyAuthenticationResponse({
  response: req.body,
  expectedChallenge: pending.challenge,
  expectedOrigin: 'http://localhost:3000',
  expectedRPID: 'localhost',
  credential, // 从服务端存储取出的 id、publicKey、counter 等
  requireUserVerification: true,
});

if (!result.verified || !req.session.active) {
  throw new Error('认证验证未通过');
}
credential.counter = result.authenticationInfo.newCounter;
```

`credential` 不是客户端提交的公钥对象。攻击者若能同时指定“要验证的签名”和“拿来验签的公钥”，就可以用自己的钥匙证明自己提交的数据。

## 一次性 challenge 为什么要先消费

示例在进入异步验证库之前，先同步取出并删除当前会话的待验证操作：

```js
function consumeChallenge(session, kind) {
  const pending = session.pending;
  delete session.pending;
  if (!session.active || !pending || pending.kind !== kind ||
      pending.expires <= now()) {
    throw new Error('挑战缺失、已使用或已过期；请重新开始');
  }
  return pending;
}
```

这样同一响应不能反复尝试，失败也必须重新申请 challenge。服务端还把待验证数据绑定到不透明会话 ID，注册操作保存服务端生成的账号 ID；请求体里没有可任意指定账号的字段。

示例只支持单进程。共享数据库、多个 Worker 或多个容器不能依靠内存删除实现相同保证，需要数据库的原子消费，并为凭据状态更新处理并发。可继续看[生产检查](#production)。

## 计数器是信号，不是绝对结论

不能要求所有认证器每次都返回更大的数字。一直为 `0` 的计数器是允许出现的；非零计数回退也不能单独证明发生克隆。[英文原文 §6.1.1](https://www.w3.org/TR/webauthn-2/#sctn-sign-counter)

本示例沿用验证库策略：接受双方都为 `0`；若任一值非零而新值不大于已存值，验证库会拒绝。验证与更新之间用单进程锁避免交错。这个策略是示例的明确选择，真实系统应结合认证器类型、并发情况和风险政策设计处置方式。[SimpleWebAuthn 官方说明](https://simplewebauthn.dev/docs/packages/server#regarding-counter)

下一步：[运行完整示例](#complete-example)，再回来看每个检查分别在哪里执行。


---

<a id="complete-example"></a>

# 完整示例：本地注册与无用户名登录

下载并运行一套完整的 Node.js 示例，从发出 challenge、调用浏览器 API，到服务器验签和建立会话。它创建**独立的匿名学习账号**，无需邮箱或密码，也不会给输入的任意用户名绑定凭据。

[下载完整示例 ZIP](https://passkey-dev-cn.pages.dev/downloads/passkey-example.zip) · [查看运行说明](https://passkey-dev-cn.pages.dev/downloads/example/README.md) · [服务端源代码](https://passkey-dev-cn.pages.dev/downloads/example/server.js) · [浏览器代码](https://passkey-dev-cn.pages.dev/downloads/example/public/app.js) · [自动测试](https://passkey-dev-cn.pages.dev/downloads/example/server.test.js)

这是在电脑本地运行的实验。本站 Pages 服务提供文档与源码下载；线上文档页不运行认证服务，也不保存读者的通行密钥记录。

## 运行条件与命令

需要 Node.js **22 或更高版本**，以及支持 WebAuthn 的浏览器和能执行本地用户验证的认证器。实际是否出现指纹、面容、PIN、安全密钥或手机选项，取决于浏览器、系统和设备配置。

ZIP 解压后，进入含有 `package.json` 的 `examples` 目录；项目源码中的目录也叫 `examples`：

```sh
cd examples
npm ci
npm test
npm start
```

浏览器打开 **[http://localhost:3000](http://localhost:3000)**。示例固定校验这个 origin 和 `localhost` RP ID，因此不要替换为 `127.0.0.1`、局域网 IP 或别的端口。端口被占用时先停止占用服务。Node.js 版本下限依据 [SimpleWebAuthn 官方文档](https://simplewebauthn.dev/docs/packages/server#installation)；安装文件锁定 `@simplewebauthn/server 14.0.3` 和 `Express 5.3.0`，便于复现。

## 按这四步练习

1. 点击“创建实验账号与通行密钥”，完成系统提示。页面显示匿名账号和凭据元数据。
2. 点击“退出当前账号”。这会替换会话，保留服务端内存中的凭据记录。
3. 点击“用通行密钥登录”，选择刚才创建的凭据并完成验证。
4. 对照登录前后的账号 ID；应回到同一个实验账号。可以在浏览器开发者工具的 Network 面板查看四个协议接口。

进程结束后，账号、公钥和 challenge 全部清空；设备上的通行密钥可能仍在。重启服务后旧凭据会找不到对应账号，可在设备的密码 / 通行密钥管理器中删除该实验记录，再重新注册。

## 文件职责

| 文件 | 阅读重点 |
| --- | --- |
| `server.js` | challenge 生命周期、服务端验证、账号归属、会话与计数更新 |
| `public/app.js` | 按钮交互、两个原生 WebAuthn API、发送 JSON 响应 |
| `public/browser.js` | JSON 中 base64url 与 WebAuthn 二进制字段转换 |
| `public/index.html` / `style.css` | 本地实验页面，无额外构建步骤 |
| `server.test.js` | 软件生成 ES256 密钥，测试真实验签与拒绝路径 |
| `package-lock.json` | 固定本次可复现的依赖树 |

## 四个接口如何串起来

| 接口 | 服务器做什么 | 浏览器接着做什么 |
| --- | --- | --- |
| `POST /api/registration/options` | 分配随机账号 ID，生成并保存注册 challenge | 转换二进制字段，调用 `create()` |
| `POST /api/registration/verify` | 消费 challenge，验证后保存账号与凭据 | 显示注册成功 |
| `POST /api/authentication/options` | 生成并保存登录 challenge，不限制凭据列表 | 调用 `get()`，让用户选择账号 |
| `POST /api/authentication/verify` | 验证归属与签名，更新计数并轮换会话 | 显示已登录账号 |

所有 POST 都要求本地 origin 和 JSON 内容类型。HttpOnly、SameSite=Strict 的 Cookie 保存不透明随机会话 ID；challenge 保留在服务器中。注册时每个浏览器会话只拥有自己的待创建账号，无法指定已有账号来添加公钥。

## 注册代码讲解

服务端创建注册选项：

```js
const options = await generateRegistrationOptions({
  rpName: 'Passkey 中文学习实验',
  rpID: 'localhost',
  userID: Buffer.from(userId, 'base64url'),
  userName: `learner-${userId.slice(0, 8)}`,
  challenge: randomBytes(32),
  attestationType: 'none',
  authenticatorSelection: {
    residentKey: 'required',
    userVerification: 'required',
  },
  supportedAlgorithmIDs: [-7, -257],
});
```

`userId` 是服务器产生的 32 字节随机标识。`residentKey: 'required'` 使示例能够采用可发现凭据的无用户名登录；`userVerification: 'required'` 同时在服务端验证中强制执行。库会生成适合 JSON 的结构，并补充 `requireResidentKey` 与 `credProps` 等选项；参数解释见[注册详解](#registration)。

浏览器把选项转换后，创建凭据并提交结果：

```js
const options = await request('registration/options');
const credential = await navigator.credentials.create({
  publicKey: registrationOptions(options),
});
if (!credential) throw new Error('没有创建凭据');
return request('registration/verify', credentialJSON(credential));
```

`request()` 是示例 `app.js` 中的同源 POST 辅助函数；`registrationOptions()` 和 `credentialJSON()` 来自 `browser.js`。上面的片段来自完整程序，需要在其上下文中阅读。

## 无用户名登录如何确认账号

服务端发出的请求没有 `allowCredentials` 限制。用户选中凭据后，服务器先根据凭据 ID 查记录，再检查 `response.userHandle` 是否与记录中的随机账号 ID 相等，最后使用已保存的公钥验证签名：

```js
const credential = credentials.get(req.body.id);
if (!credential || req.body.response?.userHandle !== credential.userId) {
  throw new Error('凭据与账号不匹配');
}
```

查到记录不代表已登录。此处之后仍要验证 challenge、origin、RP ID、UP / UV、签名与计数器，成功后才轮换会话。已登录用户点注册按钮会被阻止；想创建另一个独立实验账号时先退出。

## 测试验证了什么

`npm test` 使用 Node.js 自带测试运行器，无需额外测试框架。测试生成软件认证器响应，由真正的验证库解析 CBOR、处理 COSE 公钥并验证 ES256 签名；覆盖以下行为：

- 有效注册、退出、登录，以及成功后拒绝旧响应重放。
- 失败后 challenge 失效、操作类型约束、跨会话不可使用、五分钟过期。
- 篡改 challenge、origin、RP ID、UV 标志、签名或 user handle 均不能登录。
- 非零计数回退被拒绝，始终为零的计数器可以多次登录。
- Host / HTTP Origin 限制、请求频率上限，以及浏览器二进制转换。

软件响应测试能验证后端路径，不代替真实设备、系统 UI、同步提供方与跨设备流程测试。人工练习时仍应完成上面的四步；上线前的验证范围见[生产检查](#production)与[排错指南](#debugging)。

## 实验边界

示例只监听本机，最多存放 500 个凭据、1,000 个会话；同一 IP 每分钟最多 120 个 API 请求。会话一小时过期，challenge 五分钟过期；客户端 `timeout: 60000` 是另一项提示。

它没有持久化、多实例事务、真实身份验证、恢复、添加第二个凭据、删除凭据或线上 HTTPS 会话配置。将它移到服务器时，不能仅把 `localhost` 替换成域名就视为生产接入。继续阅读[生产系统设计](#production)。


---

<a id="modern-passkeys"></a>

# 现代 Passkey 补充：同步、条件式 UI 与能力检测

本章明确区分历史规范与后续实践：前面的核心协议以 WebAuthn Level 2 为主；这里补充 Passkey 生态和 Level 3 内容。截至本次核对的 **2026 年 10 月 16 日**，W3C 的 [Level 3 发布版](https://www.w3.org/TR/2026/REC-webauthn-3-20260825/)标示为 2026 年 8 月 25 日 Recommendation。标准发布并不等于每个浏览器已实现全部功能。

## 同步改变的是凭据可用性

Passkey 可以保存在单个设备，也可以由凭据提供方安全同步到其他设备。对网站而言，核心仍是登记公钥、验证签名；“可发现”描述如何选择凭据，“同步”描述凭据能否跨设备可用，两者不是同一个维度。[FIDO Alliance：Passkeys](https://fidoalliance.org/passkeys/)

不要将“用了平台认证器”推导为“已同步”，也不要将“安全密钥”推导为“用户无需 PIN”。注册策略应表达实际需要：可发现性用 `residentKey`，本地用户验证用 `userVerification`。选择具体设备、提供方和恢复方式则由产品目标及用户环境共同决定。

跨设备认证也不等于同步。用户可能在电脑上发起登录，再用手机中的凭据完成认证；网站并没有因此把私钥复制到该电脑。具体选项与提示由浏览器和平台决定，应保留取消与替代路径。

## BE 与 BS：两个备份标志

Level 3 为认证器数据补充了备份语义：

| 标志 | 含义 | 不应推导出的结论 |
| --- | --- | --- |
| BE，Backup Eligibility | 凭据是否具有可备份资格；创建后保持不变 | 不能据此断言用户已经在另一设备上可用 |
| BS，Backup State | 当前是否处于已备份状态；可能变化 | 不能证明用户现实身份，也不保证永久可恢复 |

[英文原文：Level 3 §6.1.3 Credential Backup State](https://www.w3.org/TR/webauthn-3/#sctn-credential-backup)。示例使用验证库给出的 `credentialDeviceType` 与 `credentialBackedUp` 保存相关信息；它们适合辅助账户恢复策略和界面提示，不应替代验签。

## 计数器不能承担完整的克隆检测

有些认证器一直返回零；不递增也可能来自故障或请求处理乱序，不能单凭一次异常认定克隆。对现代多设备凭据，更不能自行加上一条“所有登录计数必须严格大于零”的规则。风险处置应结合凭据属性和业务情境。[Level 3 §6.1.1 Signature Counter Considerations](https://www.w3.org/TR/webauthn-3/#sctn-sign-counter)

配套示例直接使用已锁定版本验证库的策略：零对零通过，非零回退拒绝；在单进程内为读、验、写加锁。若你的生产政策不同，应选择并审查支持相应行为的库或明确实现路径，不能为了绕过错误而把数据库计数器无条件清零。

## 条件式 UI：在输入框中选择通行密钥

条件式 UI（conditional mediation）让登录页的自动填充菜单显示可用通行密钥。页面预先挂起 `get()` 请求；用户选择凭据后才完成流程。它是按钮登录之外的增强，需先检测能力，并保留按钮登录入口。[web.dev：Passkey 自动填充指南](https://web.dev/articles/passkey-form-autofill)

输入框需要 `webauthn` 自动填充标记，放在 token 列表最后：

```html
<label for="username">账号</label>
<input id="username" name="username"
       autocomplete="username webauthn">
```

下面是**在本地示例基础上扩展的说明片段**；默认下载示例只实现按钮登录，不会自动发起条件式请求。它复用 `browser.js` 的 JSON 转换函数：

```js
import { authenticationOptions, credentialJSON } from './browser.js';

const controller = new AbortController();

async function conditionalLogin() {
  const available = window.PublicKeyCredential &&
    await PublicKeyCredential.isConditionalMediationAvailable?.();
  if (!available) return; // 保留已有按钮登录

  const optionResponse = await fetch('/api/authentication/options', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: '{}',
  });
  if (!optionResponse.ok) throw new Error('无法开始登录');
  const options = await optionResponse.json();

  const credential = await navigator.credentials.get({
    publicKey: authenticationOptions(options),
    mediation: 'conditional',
    signal: controller.signal,
  });
  if (!credential) return;

  const verification = await fetch('/api/authentication/verify', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(credentialJSON(credential)),
  });
  if (!verification.ok) throw new Error('验证失败，请重新开始');
  // 现在可以刷新账户页面；不能仅根据 get() 返回值显示已登录。
}

conditionalLogin().catch((error) => {
  if (error.name !== 'AbortError' && error.name !== 'NotAllowedError') {
    console.error('条件式登录未完成', error);
  }
});

// 用户点击显式登录 / 注册按钮时，先调用 controller.abort()，
// 再申请新的 options，避免与挂起请求争用同一 challenge。
```

挂起时间可能超过服务器的 challenge 有效期。接入时应设计重新申请与重试流程；不要无限延长 challenge 以适配长时间打开的标签页。一个页面也应协调好注册、条件式请求和显式登录，避免多个流程互相覆盖。

若使用 `@simplewebauthn/browser`，当前文档中的封装形式是 `startAuthentication({ optionsJSON, useBrowserAutofill: true })`，仍需合适的输入框和服务端验证。不要混用旧文章中的位置参数签名。[SimpleWebAuthn Browser 官方文档](https://simplewebauthn.dev/docs/packages/browser#browser-autofill)

## 新版 JSON API 与渐进增强

Level 3 增加了创建 / 请求选项的 JSON 解析和凭据 JSON 序列化能力。是否采用它们应逐项检测；没有能力时仍可使用示例中的显式转换，不要依据浏览器名称或版本字符串猜测。

```js
const publicKey = typeof PublicKeyCredential.parseRequestOptionsFromJSON === 'function'
  ? PublicKeyCredential.parseRequestOptionsFromJSON(optionsJSON)
  : authenticationOptions(optionsJSON);
```

这些方法改变数据转换方式，没有取消服务端验证或改变账号授权边界。[Level 3：PublicKeyCredential 接口](https://www.w3.org/TR/webauthn-3/#iface-pkcredential)

继续阅读：[从示例到生产系统](#production)。


---

<a id="production"></a>

# 从学习示例到生产系统

Passkey 解决凭据证明问题；账号归属、会话、恢复和凭据管理，仍然是应用自己的责任。本章把规范中的安全与隐私要求整理成开发检查项。

英文原文：[§13.4 RP 的安全考量](https://www.w3.org/TR/webauthn-2/#sctn-security-considerations-rp) · [§14.6 RP 的隐私考量](https://www.w3.org/TR/webauthn-2/#sctn-privacy-considerations-rp) · [§15 无障碍考量](https://www.w3.org/TR/webauthn-2/#sctn-accessiblility-considerations)。以下架构与 SQL 为学习补充。

## 首先保护“添加凭据”这一步

攻击者不一定要伪造签名。如果他能给受害者的账号绑定自己的 Passkey，以后就能正常签名登录。因此，添加凭据必须绑定到服务端已经确认的账号身份。

给现有账号添加凭据时，从经过认证的会话取账号 ID，视风险要求近期重新认证；不要直接相信请求体里的 `userId`、邮箱或用户名。新用户注册则先完成你的账号创建和身份归属流程，再把新凭据关联进去。验证码认证只能证明该次验证对应的控制权，不能自动替代所有高风险操作的身份要求。

[配套示例](#complete-example)采用独立的随机演示账号，让你练习协议；它没有实现邮箱身份验证，也不允许靠输入已有用户名给别人添加凭据。

## Challenge 的完整生命周期

[§13.4.3 的中文释义](https://www.w3.org/TR/webauthn-2/#sctn-cryptographic-challenges)：注册和认证的 challenge 必须在 RP 信任的环境中随机生成；返回值必须与发出的值匹配。不要依靠客户端维护这项约束。规范建议至少 16 字节，以提供足够的不可预测性。

实际开发可以生成 32 个密码学随机字节，并为每次操作保存以下上下文：

| 字段 | 作用 |
| --- | --- |
| `challenge` | 关联这次响应与服务端发起的操作 |
| `sessionId` 或不透明事务标识 | 防止跨会话挪用 |
| `ceremony` | 区分注册和登录，不能相互替换 |
| `accountId` | 注册绑定当前账号；已知账号登录则绑定目标账号 |
| `expiresAt` | 限制有效时间；页面上的 `timeout` 不是服务端过期校验 |
| 固定的 origin、RP ID、UV 策略 | 验证时使用可信配置，不能从响应反推 |

应以原子操作消费 challenge，避免两个请求同时通过。下面是 PostgreSQL 的**设计示意**，不是配套示例的运行依赖：

```sql
DELETE FROM webauthn_challenges
WHERE transaction_id = $1
  AND session_id = $2
  AND ceremony = 'authentication'
  AND expires_at > now()
RETURNING challenge, account_id;
```

取不到记录就拒绝；取到以后，无论本次验证成功或失败，都不能再次使用。失败的用户重新申请一个 challenge。登录成功后的凭据计数更新也要处理并发，不能让旧响应覆盖新状态。

不要把 challenge 当作第二份密码：它可以经过浏览器，但必须不可预测、有上下文、限时且只能用一次。对签名验证、安全标志和 origin 的检查仍然必不可少。

## 数据库实际保存什么

一个账号通常对应多条凭据。以下为关系模型示意：

```sql
CREATE TABLE passkey_credentials (
  credential_id TEXT PRIMARY KEY,
  account_id UUID NOT NULL REFERENCES accounts(id),
  public_key BYTEA NOT NULL,
  sign_count BIGINT NOT NULL DEFAULT 0,
  transports JSONB NOT NULL DEFAULT '[]',
  device_type TEXT,
  backed_up BOOLEAN,
  label TEXT,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  last_used_at TIMESTAMPTZ
);
CREATE INDEX passkey_account_idx ON passkey_credentials(account_id);
```

`credential_id` 用统一的 base64url 文本编码，公钥保存验证库给出的字节。还应按所用库保存它要求的算法、备份状态等元数据。`transports` 是交互提示，不是可信的身份断言。`label` 是用户起的名字，也不能据此判断设备可信度。

账号表另存稳定、随机且无个人信息的 `user_handle`，长度不得超过 64 字节。一个账号的多个凭据使用同一个 user handle。不要把邮箱、用户名或可猜测的无盐哈希塞进 `user.id`。[§14.6.1 原文](https://www.w3.org/TR/webauthn-2/#sctn-user-handle-privacy)

服务器不保存 Passkey 私钥，也不需要收到用户的指纹、面容或设备 PIN。现代凭据提供方可能在其自己的安全体系中同步私钥；这不等于你的 RP 服务端获得私钥。

## RP ID 是长期的身份边界

RP ID 不带协议、端口或路径；origin 则包括协议、主机以及非默认端口。例如 `https://login.example.com:8443` 是 origin，`example.com` 可以是符合域名规则的 RP ID。服务端必须分别验证它们。[RP ID 定义](https://www.w3.org/TR/webauthn-2/#rp-id)

把生产、预发布、本地环境分开配置。不要使用 `endsWith('example.com')` 自制 origin 白名单，因为 `evil-example.com` 也满足这个字符串条件。使用完整、明确的 origin 列表，并让验证库完成协议校验。

已有凭据与注册时的 RP ID 绑定。域名迁移前必须设计过渡和重新注册流程，不能修改数据库中的一个字符串就指望旧凭据跟随迁移。Cloudflare Pages 的预览地址也可能与正式地址不同；若以后将示例做成真正线上应用，需要以稳定域名规划 RP ID。本文发布的 Pages 站点仅承载文档与源码下载。

## 会话仍然需要保护

验证成功后，由服务器签发或轮换应用会话。生产 Cookie 通常使用 `Secure`、`HttpOnly`、适当的 `SameSite`、明确的有效期和受控作用域。退出时销毁服务端会话，敏感操作校验 CSRF 或来源，并限制尝试频率。

Passkey 抗钓鱼能力依赖正确的域绑定和验证。它不会自动修复你站点的 XSS、被盗会话、越权 API 或错误的账号恢复流程。浏览器调用成功只代表拿到了响应，不代表用户已经通过你的服务端认证。

## 计数器是风险信号

L2 描述了签名计数器，用于发现可能的凭据克隆或设备异常；它也允许不支持计数的认证器始终返回零。现代同步 Passkey 进一步要求考虑多设备场景。不能把所有凭据的计数都假定为严格递增。

保存验证库返回的计数和备份信息；结合凭据类型、并发、供应商行为和业务风险制定异常策略。不要为了兼容而关闭签名、challenge 或 origin 校验。本文示例沿用验证库的计数检查，遇到异常拒绝并重新发起；生产系统需明确自己的风险处置策略。[L2 §6.1.1](https://www.w3.org/TR/webauthn-2/#sctn-sign-counter) · [L3 §6.1.1](https://www.w3.org/TR/webauthn-3/#sctn-sign-counter)

## 恢复流程决定整体安全下限

[§13.4.6 的中文释义](https://www.w3.org/TR/webauthn-2/#sctn-credential-loss-key-mobility)：L2 本身没有定义私钥备份或在认证器之间共享私钥的协议；RP 应允许并鼓励用户为同一账号注册多个凭据。这是 2021 年规范的语境，不能据此推断今天所有 Passkey 都不可同步。

生产系统应支持多个凭据、可识别的名称、最近使用时间、逐个撤销，以及丢失全部凭据后的恢复。删除最后一个凭据时，先确认还有可用的恢复或登录途径。删除服务器记录会阻止该凭据登录本站，但通常不会自动删除设备或密码管理器里的副本。

恢复流程要与账号风险相称。一个容易被接管的邮箱恢复入口，仍可能绕过很强的 Passkey 登录。恢复成功、添加或删除凭据时，应记录审计事件并按产品政策通知用户。

## 隐私、可用性与验证

用户名存在性、`allowCredentials` 的数量和长度都可能泄露账号信息。对不存在账号与验证失败给出一致的外部错误；需要时采用无用户名的可发现凭据登录。把精确原因留给受控的服务端日志，不记录原始会话令牌、完整响应或生物信息。[§14.6.2](https://www.w3.org/TR/webauthn-2/#sctn-username-enumeration) · [§14.6.3](https://www.w3.org/TR/webauthn-2/#sctn-credential-id-privacy-leak)

上线前至少验证这些行为：

- 正常注册、退出、重新登录；一个账号绑定第二个凭据。
- challenge 过期、重放、跨会话使用，以及注册响应用于登录。
- 错误 origin、RP ID、签名、用户验证标志和凭据归属都被拒绝。
- 同一响应并发提交只成功一次；旧计数更新不会覆盖新数据。
- 用户取消、凭据不可用、没有生物识别时有清楚的提示或替代路径。
- 手机、桌面、外接安全密钥的真实操作，以及键盘和屏幕阅读器使用。

规范建议认证器提供多种用户验证方式，并给用户足够时间完成操作。不要把“人脸或指纹”写成唯一途径，也不要用过短的超时排除行动不便的用户。

学习自测：如果有人成功签名，但返回的是另一个账号的凭据，你的服务端会在哪一步拒绝？如果只能回答“前端不会这么做”，账号归属校验还没有完成。


---

<a id="debugging"></a>

# 调试、错误与测试

先确定失败发生在哪一层：选项生成、浏览器调用、JSON 编解码，还是服务端验证。把所有问题归结为“指纹失败”，会掩盖最常见的配置和协议错误。

英文原文：[§5 Web Authentication API](https://www.w3.org/TR/webauthn-2/#sctn-api) · [§5.6 中止操作](https://www.w3.org/TR/webauthn-2/#sctn-abortoperation) · [§11 用户代理自动化](https://www.w3.org/TR/webauthn-2/#sctn-automation)。下面的排查表和测试方法是开发补充。

## 一张表定位常见错误

| 现象 | 优先检查 | 正确处理 |
| --- | --- | --- |
| `PublicKeyCredential` 不存在 | 浏览器、上下文和能力检测 | 提示当前环境不可用，保留可用登录方式 |
| `SecurityError` | 是否是安全上下文；RP ID 是否符合当前域规则 | 分别检查 `location.origin` 与服务端固定 RP ID |
| `NotAllowedError` | 用户取消、超时、无可用凭据或策略限制等 | 提供重试入口；不要断言“用户取消”是唯一原因 |
| `InvalidStateError`（注册） | `excludeCredentials` 是否匹配已存在的凭据 | 告知用户可能已添加过，允许换用另一个认证器 |
| `NotSupportedError` | 算法列表、认证器能力、选项组合 | 使用受支持算法和适合产品的认证器要求 |
| `TypeError` | `challenge`、`user.id`、凭据 ID 的类型和长度 | JSON 的 base64url 必须在调用原生 API 前转成字节 |
| 页面成功弹窗，服务端拒绝 | challenge、origin、RP ID、UV、签名和账号映射 | 按验证步骤定位；不能因为弹窗成功而跳过验证 |
| 刷新、并发点击后失败 | challenge 被覆盖、消费或过期 | 禁用重复提交，为新尝试申请新 challenge |
| 同一域名换端口后失败 | expected origin 是否包含正确端口 | 为环境设置完整 origin；RP ID 不包含端口 |
| 开发能用，线上不能用 | HTTPS、正式域名、代理和 Cookie | 独立配置环境，查看真实响应状态与 Cookie |

异常类型可能随平台和具体失败阶段变化；以捕获的错误、请求状态和规范算法共同判断，不能用这张表推断唯一根因。

## 浏览器诊断片段

```js
const available = window.isSecureContext && 'PublicKeyCredential' in window;
console.table({
  origin: location.origin,
  secureContext: window.isSecureContext,
  webauthnAPI: available,
});

if (available) {
  const platformUV = await PublicKeyCredential
    .isUserVerifyingPlatformAuthenticatorAvailable();
  console.log('是否有支持用户验证的平台认证器：', platformUV);
}
```

`platformUV === false` 不代表所有 WebAuthn 登录都不可用，用户还可能使用外接认证器或其他平台提供的途径。也不能由这个布尔值推断用户是否注册过本站凭据。能力检测用于改善界面，不能作为服务端认证依据。[§5.1.7 原文](https://www.w3.org/TR/webauthn-2/#sctn-isUserVerifyingPlatformAuthenticatorAvailable)

生产环境使用 HTTPS。`http://localhost` 是浏览器认可的本地开发场景；手机访问电脑的 `http://192.168.x.x` 并不等价。设备之间联调时，需要可信 HTTPS，并同步调整固定的 origin 与 RP ID。

## 取消正在等待的调用

用户切换账号、离开页面或重新发起认证时，可以用 `AbortController` 结束旧请求：

```js
const controller = new AbortController();

document.querySelector('#cancel').addEventListener('click', () => {
  controller.abort();
});

try {
  // publicKey 来自服务端，并已把二进制字段解码为 Uint8Array
  const assertion = await navigator.credentials.get({
    publicKey,
    signal: controller.signal,
  });
  if (!assertion) throw new Error('未返回凭据');
  // 接下来序列化并提交服务端验证
} catch (error) {
  if (controller.signal.aborted) {
    console.log('本次等待已取消，可以重新开始');
  } else {
    console.error(error.name, error.message);
  }
}
```

这是 API 用法片段，要求页面中已有 `#cancel` 按钮和经过解码的 `publicKey`。不要复用已经中止的 controller。取消浏览器等待不等于服务端自动销毁 challenge，服务端仍然需要过期和一次性消费策略。

## 看懂数据，不改写签名输入

`clientDataJSON` 是原始 UTF-8 JSON 字节；可解码查看 `type`、`challenge`、`origin` 和 `crossOrigin`。登录的 `type` 应为 `webauthn.get`，注册为 `webauthn.create`。

```js
// browserResponse 是 navigator.credentials.get() 返回的凭据
const text = new TextDecoder().decode(browserResponse.response.clientDataJSON);
const clientData = JSON.parse(text);
console.log(clientData.type, clientData.origin);
```

本地调试可读字段，验证时却必须以收到的原始字节计算哈希。先解析再 `JSON.stringify()` 会改变空格、键顺序或转义方式，使签名输入改变。也不要把二进制数据错误地当成十六进制字符串或普通 Base64。

对于完整响应，优先观察字段是否存在、解码后字节数、请求关联 ID 和验证阶段；生产日志不应成为可重放响应或账号标识的集中仓库。[§5.8.1 原文](https://www.w3.org/TR/webauthn-2/#dictionary-client-data)

## 把测试分成三层

| 层次 | 验证什么 | 无法证明什么 |
| --- | --- | --- |
| 服务端单元与接口测试 | 过期、重放、会话绑定、错误参数、库的验证拒绝 | 真实浏览器弹窗和设备行为 |
| 虚拟认证器端到端测试 | 从注册、存储到登录的真实签名与 API 交互 | 生物识别、蓝牙、硬件和设备同步体验 |
| 真实设备测试 | 平台交互、取消、恢复、跨设备可用性 | 所有未来系统或浏览器版本都一致 |

L2 §11 定义虚拟认证器自动化模型。浏览器开发工具和自动化工具可以据此模拟认证器；这适合持续集成，但应另做真实设备验证。自动化中把“用户验证成功”设为真，也只是在模拟 UV，不能声称测试过真实指纹。

配套源码含可运行测试；源码 README 记录验证范围。将它作为你的测试起点，然后补充账号绑定、恢复、撤销和部署域名相关的应用测试。

## 遇到失败时的记录模板

```text
环境：浏览器 / 操作系统 / 认证器类型
操作：注册 / 有用户名登录 / 无用户名登录
配置：固定 RP ID、预期 origin（不含令牌）
阶段：options / create|get / serialize / verify
外部表现：异常 name、HTTP 状态、请求关联 ID
服务端原因：challenge expired / origin mismatch / ...
复现：是否稳定，换设备、重试或刷新后如何变化
```

一次只改变一个变量。先修复域名与编解码错误，再判断认证器兼容性；不要为排查问题临时关掉验证并把配置带到生产。


---

<a id="spec-map"></a>

# 英文规范导读与章节对照

掌握注册与登录后，回到英文规范核对边界。这份导读保留原有章节与链接，帮助你按问题定位，而不把浏览器和认证器实现算法误当作必须自己手写的业务代码。

主来源：[Web Authentication: An API for accessing Public Key Credentials — Level 2](https://www.w3.org/TR/webauthn-2/)，W3C Recommendation，2021 年 4 月 8 日。以下是章节释义与索引；完整定义、算法顺序和要求以英文原文为准。

## 规范用词如何理解

[§2 Conformance](https://www.w3.org/TR/webauthn-2/#sctn-conformance) 说明一致性要求。阅读规范时，特别注意这些词：

| 英文 | 中文理解 | 开发时的含义 |
| --- | --- | --- |
| MUST / MUST NOT | 必须 / 不得 | 一致性要求，不能为了让示例通过而忽略 |
| SHOULD / SHOULD NOT | 应当 / 不应当 | 通常遵循；例外需要理解后果和适用条件 |
| MAY | 可以 | 可选能力，不能假定所有实现都有 |
| non-normative | 非规范性 | 示例、背景和解释，不是独立的合规算法 |

同一章节可能同时包含规范性算法、说明和 Note。本文的“建议”若标为开发补充，不应被误引为 W3C 的 MUST。

## 完整章节导航

| 原文 | 中文主题 | 对本站开发者最直接的用途 |
| --- | --- | --- |
| [§1 Introduction](https://www.w3.org/TR/webauthn-2/#sctn-intro) | 简介、用例和 API 示例 | 理解两种仪式与参与者 |
| [§2 Conformance](https://www.w3.org/TR/webauthn-2/#sctn-conformance) | 一致性要求 | 区分 RP、浏览器、认证器各自的要求 |
| [§3 Dependencies](https://www.w3.org/TR/webauthn-2/#sctn-dependencies) | 外部依赖 | 找到 Web IDL、HTML、Credential Management 等基础规范 |
| [§4 Terminology](https://www.w3.org/TR/webauthn-2/#sctn-terminology) | 术语 | 澄清凭据、认证器、用户句柄和 RP ID |
| [§5 Web Authentication API](https://www.w3.org/TR/webauthn-2/#sctn-api) | 浏览器 API | 查选项、响应类型和错误条件 |
| [§6 Authenticator Model](https://www.w3.org/TR/webauthn-2/#sctn-authenticator-model) | 认证器模型 | 理解认证器数据、公钥、签名和计数器 |
| [§7 Relying Party Operations](https://www.w3.org/TR/webauthn-2/#sctn-rp-operations) | RP 操作 | **注册与认证验证的核心依据** |
| [§8 Attestation Statement Formats](https://www.w3.org/TR/webauthn-2/#sctn-defined-attestation-formats) | 证明语句格式 | 有设备来源或企业准入需求时深入 |
| [§9 WebAuthn Extensions](https://www.w3.org/TR/webauthn-2/#sctn-extensions) | 扩展机制 | 理解客户端与认证器扩展如何处理 |
| [§10 Defined Extensions](https://www.w3.org/TR/webauthn-2/#sctn-defined-extensions) | 已定义扩展 | 查 `credProps`、`largeBlob`、AppID 等 |
| [§11 User Agent Automation](https://www.w3.org/TR/webauthn-2/#sctn-automation) | 自动化 | 建立虚拟认证器测试 |
| [§12 IANA Considerations](https://www.w3.org/TR/webauthn-2/#sctn-IANA) | 标识符注册 | 实现底层格式或扩展时参考 |
| [§13 Security Considerations](https://www.w3.org/TR/webauthn-2/#sctn-security-considerations) | 安全考量 | challenge、凭据丢失、证明的局限 |
| [§14 Privacy Considerations](https://www.w3.org/TR/webauthn-2/#sctn-privacy-considerations) | 隐私考量 | 用户句柄、账号枚举和跨站关联 |
| [§15 Accessibility](https://www.w3.org/TR/webauthn-2/#sctn-accessiblility-considerations) | 无障碍 | 足够的时间、多种验证方式与提示 |
| [§16 Acknowledgements](https://www.w3.org/TR/webauthn-2/#sctn-acknowledgements) | 致谢 | 原规范贡献者 |
| [Index](https://www.w3.org/TR/webauthn-2/#index) · [References](https://www.w3.org/TR/webauthn-2/#references) · [IDL Index](https://www.w3.org/TR/webauthn-2/#idl-index) | 索引、参考文献和接口 | 精确查定义和类型 |

本文完整保留了规范的阅读入口，但没有逐字翻译所有认证器内部算法、证明格式和 Web IDL。学习与实现本站示例所需的内容，在前面章节按流程展开。

## §6：认证器数据的结构

[§6.1 Authenticator Data](https://www.w3.org/TR/webauthn-2/#sctn-authenticator-data) 定义一段字节序列，而不是 JSON 对象。其开头包含：

| 字节内容 | 长度 | 意义 |
| --- | --- | --- |
| `rpIdHash` | 32 字节 | RP ID 的 SHA-256 哈希 |
| `flags` | 1 字节 | 用户在场、用户验证及后续数据等标志 |
| `signCount` | 4 字节 | 大端无符号签名计数 |
| attested credential data | 可变，可选 | 注册公钥、凭据 ID 和 AAGUID 等 |
| extensions | 可变，可选 | 扩展输出 |

应交给成熟验证库按格式和标志解析。不能简单用“长度大于 37 字节”证明有效。现代 L3 又定义了备份相关标志；不要把 L2 未定义位的解释套在所有新凭据上。

认证断言的核心签名输入为：

```text
authenticatorData || SHA-256(clientDataJSON)
```

这里的 `||` 表示拼接原始字节。`clientDataJSON` 中包含 challenge 与 origin，`authenticatorData` 中包含 RP ID 哈希和用户操作标志。服务端必须既验证签名，也校验这些字段符合自己发出的请求。

## §6.5 与 §8：Attestation 证明什么

**Authentication assertion** 用已注册的凭据私钥证明本次认证；**Attestation** 在注册阶段表达新公钥的来源与认证器属性。后者可能涉及设备厂商、证书链和信任策略，并不证明注册者就是某个业务账号的合法主人。

常规公众站点通常可以从 `attestation: 'none'` 开始，以减少隐私和部署负担。这不等于“跳过注册验证”：仍然需要解析 attestation object，提取公钥，验证 challenge、origin、RP ID、算法、标志和其他必需条件。该选择也不能作为“私钥必定存放在某种硬件”的证据。

| 格式入口 | 中文解释 |
| --- | --- |
| [packed](https://www.w3.org/TR/webauthn-2/#sctn-packed-attestation) | 通用紧凑证明格式 |
| [TPM](https://www.w3.org/TR/webauthn-2/#sctn-tpm-attestation) | 与可信平台模块结构相关的证明 |
| [Android Key](https://www.w3.org/TR/webauthn-2/#sctn-android-key-attestation) | Android 密钥证明 |
| [Android SafetyNet](https://www.w3.org/TR/webauthn-2/#sctn-android-safetynet-attestation) | L2 中保留的历史格式；不能据此推断服务当前可用性 |
| [FIDO U2F](https://www.w3.org/TR/webauthn-2/#sctn-fido-u2f-attestation) | U2F 兼容证明 |
| [none](https://www.w3.org/TR/webauthn-2/#sctn-none-attestation) | 不提供可验证设备来源的证明语句 |
| [Apple anonymous](https://www.w3.org/TR/webauthn-2/#sctn-apple-anonymous-attestation) | Apple 匿名证明格式 |

企业若必须限制认证器型号，需要额外的证明校验、可信根与元数据、撤销处理和隐私评估。这比“设置 `direct`”多出一整套信任决策。[§13.4.4 Attestation Limitations](https://www.w3.org/TR/webauthn-2/#sctn-attestation-limitations)

## §9 与 §10：扩展是可选能力

| L2 扩展 | 用途 | 学习时的注意点 |
| --- | --- | --- |
| [appid](https://www.w3.org/TR/webauthn-2/#sctn-appid-extension) | 兼容旧 FIDO U2F 凭据 | 仅有迁移需求才引入 |
| [appidExclude](https://www.w3.org/TR/webauthn-2/#sctn-appid-exclude-extension) | 注册时排除已有旧 U2F 凭据 | 不替代新凭据的正常注册检查 |
| [uvm](https://www.w3.org/TR/webauthn-2/#sctn-uvm-extension) | 用户验证方法信息 | 可用性和隐私要分别考虑 |
| [credProps](https://www.w3.org/TR/webauthn-2/#sctn-authenticator-credential-properties-extension) | 获取凭据属性，例如 `rk` | 用于了解可发现性；客户端输出不能代替服务器身份验证 |
| [largeBlob](https://www.w3.org/TR/webauthn-2/#sctn-large-blob-extension) | 凭据关联的大数据存储 | 非所有认证器支持；不要当作通用应用数据库 |

调用 `credential.getClientExtensionResults()` 读取客户端扩展输出。某项输出缺失可能意味着未支持或未执行；根据该扩展的定义处理。客户端输出和认证器签名覆盖的扩展输出不是同一信任层次，不能把所有扩展结果都当作已签名事实。

## 按问题跳转

- 参数应该怎样传？看 [§5.4 创建选项](https://www.w3.org/TR/webauthn-2/#dictionary-makecredentialoptions) 和 [§5.5 认证选项](https://www.w3.org/TR/webauthn-2/#dictionary-assertion-options)。
- 服务端应该怎样验？看 [§7.1 注册](https://www.w3.org/TR/webauthn-2/#sctn-registering-a-new-credential) 和 [§7.2 认证](https://www.w3.org/TR/webauthn-2/#sctn-verifying-assertion)。
- 为什么需要多凭据和恢复？看 [§13.4.6](https://www.w3.org/TR/webauthn-2/#sctn-credential-loss-key-mobility)。
- 为什么不该在 `user.id` 中放邮箱？看 [§14.6.1](https://www.w3.org/TR/webauthn-2/#sctn-user-handle-privacy)。
- 需要同步、条件式 UI 或 JSON 辅助方法？转到 [现代 Passkey 补充](#modern-passkeys) 并核对 L3 原文。


---

<a id="references"></a>

# 来源、版本与译注

本项目是一份面向 Web 开发者的中文学习文档：整理翻译 WebAuthn Level 2 的核心内容，并配上独立讲解与示例代码。它不是 W3C 授权译本，不是完整逐字翻译，也不构成新的技术规范。

整理与链接核对日期：**2026 年 10 月 16 日**。若中文解释与规范产生歧义，以对应英文版本为准。浏览器能力和第三方库 API 应按各自版本再次核对。

## 主规范与原始入口

| 来源 | 版本与用途 |
| --- | --- |
| [用户提供的英文入口 §1.2.2 Authentication](https://www.w3.org/TR/webauthn-2/?utm_source=chatgpt.com#sctn-usecase-authentication) | 完整保留最初链接，含其原始查询参数 |
| [WebAuthn Level 2](https://www.w3.org/TR/webauthn-2/) | W3C Recommendation，2021-04-08；本文主要整理翻译来源 |
| [Level 2 固定版本](https://www.w3.org/TR/2021/REC-webauthn-2-20210408/) | 对照历史语境时使用的不可变版本入口 |
| [Level 2 Errata](https://www.w3.org/2021/04/webauthn-2-errata.html) | 发布后的勘误；请同时检查规范首页所指向的最新勘误入口 |
| [WebAuthn Level 3](https://www.w3.org/TR/webauthn-3/) | 现代补充来源；核对时首页标示 W3C Recommendation，2026-08-25 |
| [Level 3 固定版本](https://www.w3.org/TR/2026/REC-webauthn-3-20260825/) | 本次补充所参考的发布版本 |
| [编辑草案](https://w3c.github.io/webauthn/) | 可能持续变化；不能与上述发布版不加区分地引用 |

## 如何区分文档内容

**“中文释义”**按对应原文重述核心意思，允许调整句式以方便学习。**“开发讲解”**解释工程上的原因、误区与取舍。**“现代补充”**说明 L2 之后的 Passkey 实践或 L3 能力。**代码示例**用于演示学习，不是规范原文的逐行复刻。

规范中的浏览器内部算法和认证器操作，被整理为职责、数据结构与原文导航；没有声称全部翻译。设备证明格式、扩展和自动化的深层细节请通过[章节对照](#spec-map)回到原文。

## 示例与延伸阅读

- [SimpleWebAuthn Server 官方文档](https://simplewebauthn.dev/docs/packages/server)：配套示例的服务端验证库及其当前参数。
- [SimpleWebAuthn Browser 官方文档](https://simplewebauthn.dev/docs/packages/browser)：浏览器封装 API、JSON 转换和条件式 UI 的相关说明。
- [SimpleWebAuthn 源码](https://github.com/MasterKale/SimpleWebAuthn)：核对版本行为、测试与问题。
- [FIDO Alliance：Passkeys](https://fidoalliance.org/passkeys/)：Passkey 术语、生态和背景。
- [Chrome for Developers：Passkeys](https://developer.chrome.com/docs/identity/passkeys)：浏览器端的实现与调试资料。
- [Cloudflare Pages Direct Upload](https://developers.cloudflare.com/pages/get-started/direct-upload/)：本文静态文档站的部署方式。

本站的学习代码及运行说明可以从[完整示例](#complete-example)进入。站点本身没有收集、注册或验证读者真实凭据的线上账号系统。

## W3C 归属与许可

本文包含从 [Web Authentication: An API for accessing Public Key Credentials — Level 2](https://www.w3.org/TR/webauthn-2/) 复制、翻译或派生的材料；原规范状态为 W3C Recommendation，2021 年 4 月 8 日。

Copyright © 2021 W3C® (MIT, ERCIM, Keio, Beihang). This software or document includes material copied from or derived from Web Authentication: An API for accessing Public Key Credentials — Level 2, https://www.w3.org/TR/webauthn-2/.

保留原作者版权声明：Copyright © 2021 W3C (World Wide Web Consortium)® (MIT (Massachusetts Institute of Technology), ERCIM (European Research Consortium for Informatics and Mathematics), Keio, Beihang). W3C [liability](https://www.w3.org/policies/#disclaimers)、[trademark](https://www.w3.org/policies/#trademarks) 和 [document use](https://www.w3.org/copyright/document-license-2015/) 规则适用。原文的规范、状态和完整归属可在英文版首页查看。

现代补充也参考 [WebAuthn Level 3](https://www.w3.org/TR/webauthn-3/)。Copyright © 2026 World Wide Web Consortium. This document includes material derived from Web Authentication: An API for accessing Public Key Credentials — Level 3. 参见 [W3C document license](https://www.w3.org/copyright/document-license/)。

此中文指南用于帮助实现和学习配套软件，不作为替代 W3C 英文规范的技术标准，也不表示得到 W3C 或任何浏览器、认证器厂商的认可。原规范按其许可中列明的条件与免责声明提供。
