You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

本地环境WebAuthn调用navigator.credentials.get()失败求助

问题:FIDO2登录提示无可用注册密钥

问题背景

我正在开发一个测试Fido2-server工作流程的示例应用,完成注册(Mac Touch ID或YubiKey 5系列)后,Chrome提示该网站无已注册的密钥。

已执行步骤

  • 携带用户ID发起注册请求(配置Fido2Lib)并返回challenge
  • 接收服务器返回的注册选项,执行navigator.credentials.create
  • 将凭证发送至服务器,为用户保存公钥
  • 携带用户ID发起登录请求,返回包含allowCredentials的FIDO2 assertionOptions
  • 尝试调用navigator.credentials.get,弹出提示「该网站无可用密钥」

不同认证器的错误表现

  • 使用Touch ID(平台型)时,提示「localhost网站无可用密钥」
  • 使用YubiKey(跨平台型)时,提示「该安全密钥未注册至本网站」

怀疑是challenge或allowCredential的处理有误导致navigator.credentials.get调用失败,以下是关键代码片段:

注册请求(registerRequest)

const fido2 = new Fido2Lib({
  timeout: 60000,
  rpId: "localhost",
  rpName: "localhost:3000",
  rpIcon: "https://example.com/icon.png",
  challengeSize: 32,
  attestation: "direct",
  authenticatorAttachment: "cross-platform",
  authenticatorRequireResidentKey: false,
  authenticatorUserVerification: "preferred"
});
const user = users[userId] || {
  id: userId,
  name: username,
  displayName: username,
};
users[userId] = user;

const options = await fido2.attestationOptions();
options.user = user;
options.challenge = base64url.encode(options.challenge);
if (options.user && options.user.id) {
  options.user.id = base64url.encode(Buffer.from(options.user.id, 'utf-8'));
}

注册响应(registerResponse)

req.body.rawId = new Uint8Array(base64url.toBuffer(req.body.id)).buffer;

const attestationResult = await fido2.attestationResult(req.body, {
  challenge: user.challenge,
  origin: "http://localhost:3000",
  factor: "either"
});

if (attestationResult.audit.complete) {
  const d = attestationResult.authnrData;

  user.authenticator = JSON.stringify({
    publickey : d.get('credentialPublicKeyPem'),
    counter : d.get('counter'),
    fmt: d.get('fmt'),
    credId : base64url.encode(d.get('credId')),
  });
  users[userId] = user;

登录响应(loginResponse)

const user = users[userId];
const authenticator = JSON.parse(user.authenticator);
const assertionOptions = await fido2.assertionOptions();
assertionOptions.challenge = base64url.encode(assertionOptions.challenge);
let allowCredentials = [];
allowCredentials.push({
  type: 'public-key',
  id: authenticator.credId,
  transports: ['usb', 'nfc', 'ble']
  // transports: ['internal']
});
assertionOptions.allowCredentials = allowCredentials;

客户端注册代码(client.js for register)

const options = response.data;

options.challenge = Uint8Array.from(atob(options.challenge), (c) => c.charCodeAt(0));
options.user.id = Uint8Array.from(atob(options.user.id), (c) => c.charCodeAt(0));

const credential = await navigator.credentials.create({ publicKey: options }) as PublicKeyCredential;

const res = publicKeyCredentialToJSON(credential.response);
// publicKeyCredentialToJSON is kind of base64encode
if (credential) {
    const attestationResponse = {
        id: credential.id,
        rawId: Array.from(new Uint8Array(credential.rawId)),
        type: credential.type,
        response: res
    };

客户端登录代码(client.js for login)

const options = response.data;
options.challenge = Uint8Array.from(base64url.decode(options.challenge)).buffer;
options.allowCredentials = options.allowCredentials.map((c: any) => {
    c.id = Uint8Array.from(base64url.decode(c.id)).buffer;
    return c;
});
const assertion = await navigator.credentials.get({ publicKey: options }) as PublicKeyCredential;

Mac上已成功保存localhost的passkey,若有缺失信息请告知,或帮我指出代码中的错误。


代码错误定位与修正建议

1. 注册时编码/解码不匹配(base64 vs base64url)

服务器端用base64url.encode处理challenge和用户ID,但客户端注册代码用atob(仅支持标准base64)解码,导致数据解析错误,后续登录验证不通过。

  • 修正客户端注册代码:
// 原代码
options.challenge = Uint8Array.from(atob(options.challenge), (c) => c.charCodeAt(0));
options.user.id = Uint8Array.from(atob(options.user.id), (c) => c.charCodeAt(0));
// 改为
options.challenge = Uint8Array.from(base64url.decode(options.challenge));
options.user.id = Uint8Array.from(base64url.decode(options.user.id));

2. AllowCredentials的Transports配置错误

登录时统一设置transports: ['usb', 'nfc', 'ble'],但Touch ID属于平台型认证器,对应transport为internal,导致浏览器无法匹配凭证。

  • 修正步骤:
    1. 注册时保存认证器的实际transport信息:
    // 注册响应中修改保存逻辑
    user.authenticator = JSON.stringify({
      publickey : d.get('credentialPublicKeyPem'),
      counter : d.get('counter'),
      fmt: d.get('fmt'),
      credId : base64url.encode(d.get('credId')),
      transports: req.body.response.transports // 从注册请求中获取真实transport
    });
    
    1. 登录时使用保存的transport:
    // 登录响应中修改allowCredentials配置
    allowCredentials.push({
      type: 'public-key',
      id: authenticator.credId,
      transports: authenticator.transports || ['usb', 'nfc', 'ble', 'internal']
    });
    

3. 客户端登录Challenge处理冗余

客户端登录代码中对Challenge的转换冗余,base64url.decode返回的Buffer可直接转换为ArrayBuffer:

  • 修正:
// 原代码
options.challenge = Uint8Array.from(base64url.decode(options.challenge)).buffer;
// 改为
options.challenge = base64url.decode(options.challenge).buffer;

内容的提问来源于stack exchange,提问作者gattoman

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.19 15:03:18