通行密钥管理

本指南详细介绍了如何让用户能够查看、重命名和删除其已注册的通行密钥,同时使用 Signal API 在服务器和用户的密码管理工具之间同步已保存的凭证和用户个人资料详细信息。

服务器端操作

您的后端数据库层和端点必须支持针对已注册凭据的常见 CRUD 操作,以及更新用户个人资料详细信息。该服务器与框架专用库分离,公开了以下端点:

  1. 列出所有用户凭证:提取与已登录用户的 ID 匹配的所有 StoredPasskeyCredential 记录。
  2. 更新凭证名称:接受特定凭证 ID 的新自定义昵称,并在服务器上保留更新。
  3. 删除凭证:从数据库中移除特定凭证 ID。
  4. 更新用户账号详细信息:接受已登录用户个人资料的更新后 name(用户名)和 displayName 值,以便客户端可以将其与通行密钥提供程序同步。
// Node.js routing example for credential CRUD and user profile rename
router.get('/api/credentials', checkUserAuthenticated, async (req, res) => {
  const list = await db.findCredentialsByUserId(req.user.id);
  return res.json(list);
});

router.put('/api/credential/:id', checkUserAuthenticated, async (req, res) => {
  const { id } = req.params;
  const { name } = req.body;
  const cred = await db.findCredentialById(id);
  if (!cred || cred.passkeyUserId !== req.user.id) {
    return res.status(404).json({ error: 'Credential not found.' });
  }
  cred.name = name;
  await db.saveCredential(cred);
  return res.json(cred);
});

router.delete('/api/credential/:id', checkUserAuthenticated, async (req, res) => {
  const { id } = req.params;
  const cred = await db.findCredentialById(id);
  if (!cred || cred.passkeyUserId !== req.user.id) {
    return res.status(404).json({ error: 'Credential not found.' });
  }
  await db.deleteCredential(id);
  return res.json({ success: true });
});

router.put('/api/user', checkUserAuthenticated, async (req, res) => {
  const { name, displayName } = req.body;
  const updatedUser = await db.updateUser(req.user.id, { name, displayName });
  return res.json(updatedUser);
});

客户端管理界面

呈现一个专用设置面板,让用户可以轻松审核和管理其已注册的身份验证选项和账号资料详细信息:

  1. 显示已保存的列表:从端点提取列表并呈现各个凭据行。如果响应为空,则呈现一条有用的空状态消息(例如,“未找到通行密钥”)。
  2. 映射 AAGUID 元数据:对于每个通行密钥,在本地注册表中查找其 aaguid 属性,以呈现其提供商详细信息。如需了解详情,请参阅根据 AAGUID 确定通行密钥提供方部分。
  3. 每项界面要求:列表容器内的每一行都必须呈现:
    • 提供方图标:派生自 AAGUID 的图片或数据 URI。
    • 提供商/自定义名称:派生自 AAGUID 的名称或用户重命名的字符串。
    • Registration Date:数据库中持久化的原始纪元时间戳 registeredAt,已转换为人类可读的日期格式,以便在客户端显示。
    • 上次使用日期:数据库中保留的原始纪元时间戳 lastUsedAt,已转换为人类可读的日期格式(如果存在),以便在客户端显示。
    • “重命名”按钮:触发重命名操作,以更新服务器上凭证的自定义昵称 (PUT /api/credential/:id)。注意:重命名单个通行密钥的昵称是服务器端标签更改,不会调用 signalCurrentUserDetails()。
    • “删除”按钮:触发凭据删除操作 (DELETE /api/credential/:id),并通过 signalAllAcceptedCredentials() 同步剩余的凭据 ID。
  4. 用户账号重命名界面:
    • 提供一个控件,让用户能够更新其账号用户名 (name) 或 displayName (PUT /api/user),并通过 PublicKeyCredential.signalCurrentUserDetails() 立即将更新后的用户详细信息与密码管理工具同步。
  5. 条件式“创建通行密钥”按钮:
    • 在管理页面上提供醒目的“创建通行密钥”注册触发按钮。在呈现此界面元素之前,网页必须使用 PublicKeyCredential.getClientCapabilities() 检测功能,以验证是否支持平台身份验证器。如果不支持通行密钥,请隐藏此按钮,并以温和的方式鼓励用户注册标准 MFA。
    • 允许在 navigator.credentials.create() 调用中省略 authenticatorSelection.authenticatorAttachment 来注册安全密钥。

Signal API 同步

借助 Signal API,应用可以向密码管理工具传达凭证状态,从而使用户的同步保险柜和您的后端数据库保持同步。

  • 特征检测和参数编码规则:
    • 在调用 Signal API 方法(if (PublicKeyCredential.signalAllAcceptedCredentials) 和 if (PublicKeyCredential.signalCurrentUserDetails))之前,请务必先进行功能检测。
    • 传递给 Signal API 方法(signalAllAcceptedCredentials、signalCurrentUserDetails)的所有 userId 和凭证 ID 参数都必须是 Base64URL 编码的字符串。
  • 启动网页加载同步:
    • 当管理页面加载时,应用必须自动调用 signalAllAcceptedCredentials()(例如,在 DOMContentLoaded 事件监听器、模块初始化或框架组件装载钩子 [如 useEffect] 中)。
  • 管理更新同步:
    • 应用必须在提取后立即在删除凭证点击处理程序中调用 signalAllAcceptedCredentials()。
    • 应用必须在用户账号重命名(name 或 displayName)点击处理程序提取后立即调用 signalCurrentUserDetails()。
// Client-side management synchronization ES module
import { listFetch, renameCredentialFetch, updateUserFetch, deleteFetch } from './api.js';

// Base64URL-encoded User ID string (illustration only)
const base64UrlUserId = "M2YPl-KGnA8";

async function syncAcceptedCredentials(currentCredentialsList) {
  if (!window.PublicKeyCredential || !PublicKeyCredential.signalAllAcceptedCredentials) return;
  try {
    const credentialIds = currentCredentialsList.map(c => c.id); // Array of Base64URL credential ID strings
    
    await PublicKeyCredential.signalAllAcceptedCredentials({
      rpId, // RP ID must match the one defined on the server
      userId: base64UrlUserId, // User ID Base64URL-encoded string
      allAcceptedCredentialIds: credentialIds
    });
  } catch (e) {
    console.error('SignalAllAcceptedCredentials sync failure:', e);
  }
}

async function loadManagementPanel() {
  const response = await listFetch();
  const list = await response.json();
  
  renderUI(list);
  // Sync accepted credentials on initial load
  await syncAcceptedCredentials(list);
}

async function performDelete(credentialId) {
  const response = await deleteFetch(credentialId);
  if (response.ok) {
    const updatedResponse = await listFetch();
    const updatedList = await updatedResponse.json();
    
    renderUI(updatedList);
    // Sync remaining accepted credentials after deletion
    await syncAcceptedCredentials(updatedList);
  }
}

// Renaming a passkey's nickname updates the server record only
async function performCredentialRename(credentialId, newCredentialName) {
  const response = await renameCredentialFetch(credentialId, { name: newCredentialName });
  if (response.ok) {
    const updatedResponse = await listFetch();
    renderUI(await updatedResponse.json());
  }
}

// Renaming the user's account username or displayName triggers signalCurrentUserDetails
async function performUserRename(rpId, userId, updatedName, updatedDisplayName) {
  const response = await updateUserFetch({ name: updatedName, displayName: updatedDisplayName });
  if (response.ok && window.PublicKeyCredential && PublicKeyCredential.signalCurrentUserDetails) {
    try {
      await PublicKeyCredential.signalCurrentUserDetails({
        rpId, // RP ID must match the one defined on the server
        userId, // Base64URL-encoded user ID
        name: updatedName, // Updated username
        displayName: updatedDisplayName // Updated display name
      });
    } catch (e) {
      console.error('SignalCurrentUserDetails sync failure:', e);
    }
  }
}

window.addEventListener('DOMContentLoaded', loadManagementPanel);

根据 AAGUID 确定通行密钥提供方

AAGUID(身份验证器认证全局唯一标识符)是一个 128 位标识符,表示身份验证器的型号,而非特定实例。它会在通行密钥注册期间包含在身份验证器数据中,可用于确定哪个通行密钥提供方(例如 Google 密码管理工具、iCloud 钥匙串、1Password)创建了凭证。

AAGUID 只能用于帮助用户管理通行密钥。除非经过加密认证,否则可以修改,而平台通行密钥目前不支持加密认证。

1. AAGUID 注册表

如需查看由社区维护的 AAGUID 与提供商名称和图标的 JSON 映射,请访问:

https://raw.githubusercontent.com/passkeydeveloper/passkey-authenticator-aaguids/refs/heads/main/combined_aaguid.json

每个条目都具有以下架构:

{
  "<aaguid-uuid>": {
    "name": "Provider Name",
    "icon_light": "data:image/png;base64,...",
    "icon_dark": "data:image/png;base64,..."
  }
}

2. 注册后使用 AAGUID

验证注册响应后,从注册结果中读取 aaguid,并在注册表中查找该 ID,以填充凭据的 name 和 providerIcon:

在注册表中查找 AAGUID 之前,请先检查它是否等于 '00000000-0000-0000-0000-000000000000'。如果是,请跳过注册表查找,并将 name 设置为后备(例如,来自用户代理的设备名称或“未知的通行密钥提供方”),并将 providerIcon 设置为 undefined。仅针对非零 AAGUID 查找注册表。

import aaguids from './aaguids.json' with { type: 'json' };

const { aaguid } = registrationInfo;
if (aaguid === '00000000-0000-0000-0000-000000000000') {
  // use the device name as the passkey provider based on
  // the information derived from the user agent string,
  // or just say "Unknown passkey provider"
} else {
  const provider = aaguids[aaguid];
  const credential = {
    // ...other fields
    aaguid,
    name: provider?.name || 'Unknown passkey provider',
    providerIcon: provider?.icon_light,
  };
}

后备策略

通行密钥功能检测回退

始终安装“webauthn-polyfills”并将其导入到上下文中。 请注意,只要支持 PublicKeyCredential,就支持 PublicKeyCredential.getClientCapabilities。

import 'webauthn-polyfills';

Signal API 同步回退

如果浏览器不支持 PublicKeyCredential.parseRequestOptionsFromJSON,请使用“webauthn-polyfills”:

<script type="module"> 
  if (!PublicKeyCredential.parseRequestOptionsFromJSON) { 
     await import('https://unpkg.com/webauthn-polyfills'); 
   } 
 </script> 

这还会添加对 PublicKeyCredential.prototype.toJSON 的支持。