跳到主要内容
ecdsa.com
← 全部文章

面向开发者的通行密钥指南:实测 WebAuthn,不靠猜测

通行密钥是一种 WebAuthn 凭证,用户无需输入用户名或密码就能用它登录。浏览器 API 很小,围绕它要做的决策却不少。本文完整演示两个流程,说明服务器必须检查什么,并列出 Chromium 抛出的确切错误,全部来自一次针对虚拟认证器的实际运行。

简短回答:通行密钥(passkey)是一种 WebAuthn 可发现凭证:由认证器(手机、笔记本电脑的安全硬件、密码管理器或安全密钥)为某一个网站(即依赖方)创建的密钥对。注册时,服务器收到一个公钥;登录时,服务器收到对其挑战值的签名。对每个响应,服务器都必须检查挑战值、源、RP ID 和用户验证标志。请求 residentKey: "required" 和 userVerification: "required",提供 -8、-7 和 -257 作为算法;除非你是需要限制认证器型号的企业,否则把证明保持为 "none";并且要预计到大多数通行密钥会在设备之间同步。

一张表看懂基本模型

术语对你的代码意味着什么
依赖方(RP)和 RP ID你的网站。RP ID 是一个域名,例如 example.com;凭证在该域名及其子域名上有效,在其他任何地方都不能使用
可发现凭证与用户句柄一起存储在认证器上,因此登录时不需要用户名。WebAuthn Level 3 把“passkey”列为它的另一个名称
用户句柄(user.id)你的不透明账号 ID,最长 64 字节,登录时返回。绝不能是电子邮件地址
用户在场(UP)和用户验证(UV)UP:有人触摸或确认过。UV:认证器检查了 PIN 或生物识别。二者都是认证器数据中的标志
可备份(BE)和已备份(BS)可以同步或已经同步到其他设备的凭证:即同步通行密钥,规范中称为“多设备凭证”
证明和 AAGUID一条可选的证书链,说明密钥由哪种认证器型号生成;AAGUID 标识该型号

认证器数据中的标志位分别为:第 0 位 UP,第 2 位 UV,第 3 位 BE,第 4 位 BS,第 6 位 AT(包含已证明凭证数据),第 7 位 ED(扩展)(WebAuthn Level 3 §6.1)。WebAuthn Level 3 于2026年8月25日成为 W3C 推荐标准。

服务器:两个流程

下面是一个用 @simplewebauthn/server 14.0.3 实现的完整依赖方。为了简短,数据存放在内存中;在生产环境中,这三个映射是数据库表,挑战值则保存在用户的服务端会话中。

server.mjs(@simplewebauthn/server 14.0.3)
// Relying party: registration and sign-in ceremonies with @simplewebauthn/server 14.0.3.
import {
  generateAuthenticationOptions,
  generateRegistrationOptions,
  verifyAuthenticationResponse,
  verifyRegistrationResponse,
} from "@simplewebauthn/server";

const rpID = "localhost"; // the site's registrable domain in production, e.g. "example.com"
const origin = "http://localhost:3000"; // every origin allowed to use this RP ID
const users = new Map(); // userName -> { id, credentials: [] }
const credentials = new Map(); // credential ID -> { userName, publicKey, counter, transports }
const pending = new Map(); // ceremony ID -> expected challenge, single use

export async function registrationOptions(userName, { attestation = "none" } = {}) {
  const user = users.get(userName) ?? { id: crypto.getRandomValues(new Uint8Array(16)), credentials: [] };
  users.set(userName, user);
  const options = await generateRegistrationOptions({
    rpName: "Example",
    rpID,
    userName,
    userID: user.id,
    attestationType: attestation,
    authenticatorSelection: { residentKey: "required", userVerification: "required" },
    supportedAlgorithmIDs: [-8, -7, -257], // EdDSA, ES256, RS256
    excludeCredentials: user.credentials.map((id) => ({ id, transports: credentials.get(id).transports })),
  });
  pending.set(`reg:${userName}`, options.challenge);
  return options;
}

export async function finishRegistration(userName, response) {
  const expectedChallenge = pending.get(`reg:${userName}`);
  pending.delete(`reg:${userName}`);
  const { verified, registrationInfo: info } = await verifyRegistrationResponse({
    response,
    expectedChallenge,
    expectedOrigin: origin,
    expectedRPID: rpID,
    requireUserVerification: true,
  });
  if (!verified) throw new Error("registration not verified");
  const { credential } = info;
  credentials.set(credential.id, { userName, publicKey: credential.publicKey, counter: credential.counter, transports: credential.transports });
  users.get(userName).credentials.push(credential.id);
  return info;
}

export async function authenticationOptions() {
  // Empty allowCredentials: the authenticator offers its discoverable credentials (passkeys).
  const options = await generateAuthenticationOptions({ rpID, userVerification: "required" });
  pending.set(`auth:${options.challenge}`, options.challenge);
  return options;
}

export async function finishAuthentication(response, challenge) {
  if (!pending.delete(`auth:${challenge}`)) throw new Error("unknown or used challenge");
  const stored = credentials.get(response.id);
  if (!stored) throw new Error("unknown credential");
  const { verified, authenticationInfo } = await verifyAuthenticationResponse({
    response,
    expectedChallenge: challenge,
    expectedOrigin: origin,
    expectedRPID: rpID,
    credential: { id: response.id, publicKey: stored.publicKey, counter: stored.counter, transports: stored.transports },
    requireUserVerification: true,
  });
  if (!verified) throw new Error("authentication not verified");
  stored.counter = authenticationInfo.newCounter;
  return { userName: stored.userName, ...authenticationInfo };
}

两个验证调用各检查什么,以及每项检查为什么重要:

  • 挑战值。随机、一次性,并绑定到当前流程。带着旧挑战值的响应就是重放。
  • 源。浏览器把页面的源写入 clientDataJSON,认证器对它的哈希签名。把它与你的源列表比较,正是通行密钥能够抗钓鱼的原因:仿冒网站产生的响应带的是它自己的源。
  • RP ID 哈希。认证器数据以 RP ID 的 SHA-256 哈希开头;为其他网站创建的凭证无法用在你的网站上。
  • 标志。UP 必须置位;当你只依赖通行密钥本身完成登录时,UV 也必须置位,而这正是通行密钥的意义所在。
  • 签名:覆盖认证器数据和客户端数据哈希,用注册时保存的公钥验证。
  • 计数器。许多同步通行密钥始终报告 0。只有涉及非零值时,WebAuthn 才把计数器减小视为可能的克隆(§6.1.1);切勿因为计数器为 0 而拒绝。

浏览器:两个调用

browser.js
// Browser side of both ceremonies. JSON options from the server need their base64url
// fields turned into ArrayBuffers; the static helpers on PublicKeyCredential do that.
async function register(optionsJSON) {
  const publicKey = PublicKeyCredential.parseCreationOptionsFromJSON(optionsJSON);
  const credential = await navigator.credentials.create({ publicKey });
  return credential.toJSON(); // send to the server as-is
}

async function signIn(optionsJSON) {
  const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(optionsJSON);
  const credential = await navigator.credentials.get({ publicKey });
  return credential.toJSON();
}

parseCreationOptionsFromJSON、parseRequestOptionsFromJSON 和 toJSON 属于 WebAuthn Level 3,取代了以往每个项目都要自带的 base64url 转换代码。要让用户名字段在浏览器的自动填充中提供通行密钥,先检查 PublicKeyCredential.isConditionalMediationAvailable(),然后在页面加载时用 mediation: "conditional" 调用 navigator.credentials.get(),并给输入框加上 autocomplete="username webauthn"。

端到端实测

实验用 Playwright 1.64.1(mcr.microsoft.com/playwright:v1.64.1-noble)驱动 Chromium,并通过 Chrome DevTools 协议添加了一个虚拟认证器。http://localhost:3000 上的页面运行 browser.js;驱动脚本把 server.mjs 生成的选项传入页面,再把响应传出来。每一行都来自一次真实的注册或登录:

输出(Chromium 156.0.8078.4,Playwright 镜像中的 Node.js)
chromium 156.0.8078.4, virtual platform authenticator with user verification
registration options: challenge 43 chars, pubKeyCredParams -8, -7, -257, attestation none
registered: fmt none, COSE alg -8, transports internal, credentialDeviceType singleDevice, backedUp false, userVerified true, aaguid 01020304-0506-0708-0102-030405060708
second registration on the same authenticator: InvalidStateError: The user attempted to register an authenticator that contains one of the credentials already registered with the relying party.
signed in as alice: userVerified true, newCounter 2, credentialBackedUp false
replayed assertion: unknown or used challenge
response created on http://localhost:4000: Unexpected registration response origin "http://localhost:4000", expected "http://localhost:3000"
synced passkey, attestation "direct": fmt packed, credentialDeviceType multiDevice, backedUp true
sign-in when the authenticator cannot verify the user: NotAllowedError: The operation either timed out or was not allowed. See: https://www.w3.org/TR/webauthn-2/#sctn-privacy-considerations-client.

每一行说明了什么:

  • 算法。选项提供了 -8、-7 和 -257;认证器选择了 -8(EdDSA),即其中第一个它支持的算法。顺序表示偏好。
  • excludeCredentials 有效:它触发了 InvalidStateError,你的界面可以据此提示“这台设备上已经有这个账号的通行密钥”。
  • 无需用户名的登录。allowCredentials 为空;认证器提供了它的可发现凭证,服务器通过凭证 ID 及其保存的公钥找到了对应账号。
  • 重放在挑战值这一步失败。挑战值一经使用就删除,正是这一点让第二次尝试失败。
  • 浏览器不会拦下所有错误的源。http://localhost:4000 上的页面可以为 RP ID localhost 创建凭证,因为 RP ID 覆盖该域名的所有端口和子域名。只有服务器的源检查拒绝了它:Unexpected registration response origin。expectedOrigin 应保持为一个精确的列表。
  • 同步通行密钥会表明自己的身份。开启可备份(BE)后,SimpleWebAuthn 报告了 multiDevice 和 backedUp true。
  • 浏览器会强制执行“要求 UV”。认证器无法验证用户时,浏览器拒绝了请求,报出通用的 NotAllowedError;服务端的 requireUserVerification 是第二道防线。

与密码并行推出通行密钥

很少有网站能一次性切换所有账号。WebAuthn Level 3 补上了逐步推出所需的各个部分,它们都可以通过 PublicKeyCredential.getClientCapabilities() 检测到:

  • 用户用密码登录后,立即提供通行密钥。具备 conditionalCreate 能力时,用 mediation: "conditional" 调用 navigator.credentials.create(),刚刚填入密码的密码管理器就能直接创建通行密钥,无需额外的对话框。
  • 从用户名字段登录。conditionalGet 能力就是上文介绍的自动填充流程;有通行密钥的用户直接选择它,其他人照常输入密码。
  • 让认证器中的列表与你的列表保持同步。用户在你的账号设置中删除通行密钥后,认证器仍会显示它。signalUnknownCredential() 告诉认证器某个凭证 ID 已不再被接受,signalAllAcceptedCredentials() 发送某个已登录用户的完整凭证列表,signalCurrentUserDetails() 则在用户重命名账号后更新显示的名称(§5.1.10)。
  • 多个域名,一个 RP ID。在 example.com 和 example.co.uk 上都提供登录的品牌,可以只保留一个 RP ID,并在 https://example.com/.well-known/webauthn 中以 {"origins": [...]} 的形式列出其他源。当 RP ID 不是页面自身的域名时,浏览器会获取这个文件(相关源请求,§5.11);如果获取失败,结果就是下文所示的 SecurityError。
  • 为每个凭证记录 BE 和 BS。如果用户唯一的通行密钥只存在于单台设备上且没有备份,丢失那台设备就会失去访问权限;在取消用户的密码之前,提示其再添加一个通行密钥。

证明:什么时候需要

证明回答的是“这个密钥由哪种认证器型号生成”。它不涉及用户本身,也不说明设备的补丁级别或是否已获取 root 权限。使用默认值 attestation: "none" 时,浏览器会在你的服务器看到证明声明之前,把它替换为空声明。实验中服务器仍然收到了认证器的 AAGUID:WebAuthn Level 2 要求浏览器在这种情况下把它替换为 16 个零字节,而 Level 3 去掉了这一步。没有证明时,AAGUID 只是一种声明,不能作为证据。

面向消费者的网站不要要求证明。同步通行密钥的设计初衷是跟随用户到任何设备上,而它们的提供商通常没有为其提供证明:Google 在 Android 上推出通行密钥时表示,它们“最初不会带有任何证明”(fido-dev 邮件列表,2022年)。实验中的虚拟认证器在被请求 "direct" 时返回了 packed 格式的声明,这说明了提供证明的认证器会发送什么,却不能说明某个具体的同步提供商会怎么做,所以要测试你的用户实际使用的提供商。当你必须把注册限制在一组型号内(例如公司发放的安全密钥)时,再请求证明,并对照 FIDO 元数据服务验证证书链。如果想向用户显示其通行密钥提供商的友好名称(“Google Password Manager”“1Password”),社区维护的通行密钥认证器 AAGUID 列表把 AAGUID 映射到名称;它自己的 README 也写明,该列表仅用于显示,不能作为安全决策的依据。

COSE 算法

pubKeyCredParams 列出 COSE 算法标识符(IANA COSE 注册表)。规范自己的示例接受 EdDSA、ES256 和 RS256,并优先选择 EdDSA;这个列表几乎覆盖了目前在用的所有认证器:

值名称说明
-8EdDSA在 WebAuthn 中仅指 Ed25519(§5.8.5);RFC 9864 已在 COSE 注册表中将其标为弃用,改用 -19 Ed25519,但认证器仍然输出 -8
-7ES256ECDSA P-256;支持最广泛。在 COSE 中已弃用,改用 -9 ESP256,情况相同
-257RS256RSA PKCS#1 v1.5;Windows 10 和部分 Windows 11 安装中的 Windows Hello 只创建 RS256 凭证(Yubico 在 W3C 邮件列表上的说明)
-48, -49, -50ML-DSA-44, -65, -87由 RFC 9964 注册(2026年5月)。Chromium 156 的虚拟认证器接受了 -49(见下文);在依赖它们之前,先检查真实的认证器和你的服务器端库

即使你更倾向于 -8,也要在列表中保留 -7 和 -257:pubKeyCredParams 为空时,规范自己的默认值就是 ES256 和 RS256。把算法与凭证一起保存;SimpleWebAuthn 把它保存在所存储的 COSE 公钥中。

DOMException 错误

navigator.credentials.create() 和 get() 通过一小组异常报告失败,这些异常列在 WebAuthn Level 3 的 §5.1.3.1 和 §5.1.4.3 中。下面是 Chromium 156 针对常见错误抛出的异常,测试对象是一个不能存储可发现凭证的虚拟 USB 安全密钥:

输出:errors.mjs(Chromium 156.0.8078.4)
only an unknown algorithm (-65000)           NotAllowedError: The operation either timed out or was not allowed. See: https://www.w3.org/TR/webauthn-2/#sctn-privacy-considerations-client.
only ML-DSA-65 (-49)                         created, COSE alg -49
residentKey required, key cannot store it    NotAllowedError: The operation either timed out or was not allowed. See: https://www.w3.org/TR/webauthn-2/#sctn-privacy-considerations-client.
rp.id "example.com" on localhost             SecurityError: The relying party ID is not a registrable domain suffix of, nor equal to the current domain. Subsequently, an attempt to fetch the .well-known/webauthn resource of the claimed RP ID failed.
user.id of 65 bytes                          TypeError: User handle exceeds 64 bytes.
aborted with AbortController                 AbortError: signal is aborted without reason
异常规范中的含义怎么处理
NotAllowedError兜底错误:用户取消、超时、没有匹配的凭证、没有任何认证器满足要求视为“未完成”;提供重试和其他登录方式。切勿显示原始消息
InvalidStateError认证器识别出了 excludeCredentials 中的某一项告诉用户这台设备上已经有本站的通行密钥
SecurityErrorRP ID 既不是页面的域名,也不是它的可注册后缀,或者相关源校验失败修正配置;参见 SecurityError 页面
NotSupportedErrorpubKeyCredParams 中没有可用的算法提供 -7 和 -257
ConstraintError要求了 residentKey 或 userVerification,但没有能满足要求的认证器实践中与 NotAllowedError 同样处理
AbortError你的 AbortController 取消了调用从自动填充切换到模态提示时属于预期情况
TypeError选项格式错误,例如用户句柄超过 64 字节你构造的选项有 bug

实验中有两个结果与规范的列表不同:唯一的算法未知时,Chromium 报出的是 NotAllowedError,而不是 NotSupportedError;要求一个无法存储可发现凭证的安全密钥提供可发现凭证时,报出的也是 NotAllowedError,而不是 ConstraintError。靠 NotSupportedError 或 ConstraintError 分支向用户解释问题的代码,在 Chromium 中遇到这些情况时永远收不到这两种异常。这两个 API 都只存在于安全上下文中。在不是 localhost 的普通 HTTP 页面上,Chromium 156 报告 isSecureContext: false,navigator.credentials、PublicKeyCredential 和 crypto.subtle 全部为 undefined,因此代码在任何流程开始之前就会抛出 TypeError(Web Crypto 的对应情况见 crypto.subtle is undefined)。所有使用通行密钥的页面都要通过 HTTPS 提供;http://localhost 是唯一的例外,仅用于开发。

检查清单

  1. RP ID 设为用户看到的可注册域名;expectedOrigin 是一个明确的 HTTPS 源列表,包含每一个提供登录的子域名。
  2. residentKey: "required"、userVerification: "required",服务器对每个响应都检查 UV。
  3. pubKeyCredParams 包含 -8、-7、-257;excludeCredentials 填入该用户已有的凭证。
  4. 挑战值随机、一次性、有效期短,并保存在服务端。
  5. 保存凭证时包含 ID、公钥、计数器、传输方式、BE 和 BS 标志、AAGUID 以及用户可编辑的名称;用户可以注册多个凭证,也可以删除它们。
  6. 除非你要限制认证器型号,否则证明设为 "none";需要限制时,对照 FIDO MDS 验证。
  7. 账号恢复不能比通行密钥本身更弱:使用第二个通行密钥或经过验证的恢复途径,而不是一条能绕过一切的短信验证码。

来源