注册的目标是:让认证器创建一个面向本网站的凭据,然后由服务器验证并绑定到正确账户。navigator.credentials.create() 只完成浏览器侧的凭据创建;服务器验证成功,才算业务上的添加完成。
英文原文 §5.4 创建选项 · §5.2.1 注册响应 · §7.1 服务端注册算法
开始之前:先确定注册权限#
对于已有账户,注册接口应从可信登录会话读取账户 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 算法标识
为通行密钥选择创建策略#
本书学习示例采用以下策略,适合练习可发现凭据与本地用户验证:
// 这是服务器产生创建选项时采用的策略片段。
const registrationPolicy = {
authenticatorSelection: {
residentKey: "required",
requireResidentKey: true,
userVerification: "required",
},
attestation: "none",
extensions: { credProps: true },
};residentKey: "required" 要求创建可发现凭据;无法满足时应失败。requireResidentKey: true 用于兼容较旧实现,保持与 residentKey 的要求一致。userVerification: "required" 要求完成本地用户验证,后端也必须执行对应检查。英文原文 §5.4.4 选择条件
这里没有强制 authenticatorAttachment: "platform",以便允许浏览器呈现适合的认证器选择。设置 platform 会限制为平台认证器,并不能保证“同步到所有设备”;设置 cross-platform 也不等于“必须是 USB”。选择附着类型应服务于业务要求,不应仅为了让演示弹出某一种系统窗口。
residentKey: "preferred" 可以扩大兼容范围,但可能得到不可发现凭据。若采用它,产品必须准备先输入用户名等登录路径,不能保证所有注册结果都能进行无用户名登录。credProps.rk 输出可帮助了解创建结果,缺少输出也不能简单推断为 false。英文原文 §10.4 Credential Properties
JSON 与二进制:最常见的接入错误#
HTTP JSON 不直接表达 ArrayBuffer。通常约定将二进制字段编码为 Base64URL 字符串;浏览器收到后,把 challenge、user.id、excludeCredentials[].id 解码为字节,再调用 create()。
Base64URL 用 -、_ 代替普通 Base64 的 +、/,通常省略末尾的 =。它是编码方式,不是加密。不要用 TextEncoder.encode(base64urlString) 解码:那会得到“编码字符串本身”的 UTF-8 字节。
以下辅助函数针对本文的无填充 Base64URL 约定;真实项目可使用经过验证的库。服务器仍须限制输入大小、验证编码与结构,不应把浏览器侧校验当成安全防线。
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 验证并保存凭据,通过服务端会话绑定账户和挑战。可运行的后端与浏览器源码见完整示例。
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、把注册结果提交给另一个账户会话。这些情况必须由服务器拒绝,而不仅仅是在正常路径上看到一次“添加成功”。