密碼金鑰管理

本指南將詳細說明如何讓使用者查看、重新命名及刪除已註冊的密碼金鑰,同時使用 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. 個別項目 UI 規定:清單容器中的每一列都必須顯示:
    • Provider Icon:衍生自 AAGUID 的圖片或資料 URI。
    • 供應商/自訂名稱:衍生自 AAGUID 的名稱或使用者重新命名的字串。
    • 註冊日期:資料庫保存的原始 Epoch 時間戳記 registeredAt,已轉換為人類可讀的日期格式,方便用戶端顯示。
    • 上次使用日期:資料庫保留的原始紀元時間戳記 lastUsedAt,格式為人類可讀的日期 (如有),用於用戶端顯示。
    • 「Rename」按鈕:觸發重新命名動作,更新伺服器上的憑證自訂暱稱 (PUT /api/credential/:id)。注意:重新命名個別密碼金鑰的暱稱是伺服器端標籤變更,不會叫用 signalCurrentUserDetails()。
    • 刪除按鈕:觸發刪除憑證 (DELETE /api/credential/:id),並透過 signalAllAcceptedCredentials() 同步處理剩餘的憑證 ID。
  4. 使用者帳戶重新命名 UI:
    • 提供控制項,讓使用者更新帳戶使用者名稱 (name) 或displayName (PUT /api/user),並透過 PublicKeyCredential.signalCurrentUserDetails() 立即將更新後的使用者詳細資料同步至密碼管理工具。
  5. 條件式「建立密碼金鑰」按鈕:
    • 在管理頁面上提供醒目的「建立密碼金鑰」註冊觸發按鈕。在顯示這個 UI 元素前,網頁必須使用 PublicKeyCredential.getClientCapabilities() 偵測功能,確認是否支援平台驗證器。如果系統不支援密碼金鑰,請隱藏這個按鈕,並改為鼓勵使用者註冊標準多重驗證。
    • 允許在 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 (驗證器認證全域專屬 ID) 是 128 位元的 ID,代表驗證器的型號,而非特定執行個體。在註冊密碼金鑰時,這項資訊會包含在驗證器資料中,可用於判斷哪個密碼金鑰提供者 (例如 Google 密碼管理工具、iCloud 鑰匙圈、1Password) 建立了憑證。

AAGUID 只能用於協助使用者管理密碼金鑰。除非經過加密認證,否則可以修改。平台密碼金鑰目前不支援加密認證。

1. AAGUID 註冊

社群維護的 JSON 對應 (將 AAGUID 對應至供應商名稱和圖示) 位於:

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,並在註冊表中查詢,以填入憑證的 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 的支援。