指南/第 04 章

CHAPTER 04 / 12

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

注册的目标是:让认证器创建一个面向本网站的凭据,然后由服务器验证并绑定到正确账户。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 算法标识

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

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

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 选择条件

这里没有强制 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 约定;真实项目可使用经过验证的库。服务器仍须限制输入大小、验证编码与结构,不应把浏览器侧校验当成安全防线。

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 验证并保存凭据,通过服务端会话绑定账户和挑战。可运行的后端与浏览器源码见完整示例。

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、把注册结果提交给另一个账户会话。这些情况必须由服务器拒绝,而不仅仅是在正常路径上看到一次“添加成功”。

读懂原理之后,用示例验证你的理解。

搜索指南

输入关键词,查找你想了解的内容。

搜索覆盖全部 12 章 · 点击结果定位到相关段落
学习目录