昨天,我們認識了 WebAuthn 世界中的三大主角,並了解了「註冊 (Attestation)」與「驗證 (Assertion)」這兩大核心流程。今天,我們將深入第一階段——註冊。這不僅是簡單的 API 呼叫,而是一場跨越前後端與硬體的信任建立過程,最終目的是簽訂一份嚴密的「密碼學契約」。
密碼學契約的核心構想:
「我,網站的擁有者(信賴方),從此刻起,相信這把獨一無二的公鑰,能夠代表這位使用者。」
讓我們一起拉開序幕,一步步解構註冊流程的每一個細節 🔐
在深入程式碼之前,先用一張序列圖 (Sequence Diagram) 來鳥瞰整個註冊流程。理解各個角色(使用者、瀏覽器、後端伺服器、驗證器)之間是如何溝通的。
流程拆解為八個關鍵步驟:
- 請求發起:流程始於前端,它向後端請求一份「註冊選項」。
- 後端出題:後端伺服器(RP)產生一份包含隨機挑戰碼
challenge
的選項,回傳給前端。- 呼叫瀏覽器:前端使用
navigator.credentials.create()
API,將後端的選項交給瀏覽器處理。- 使用者互動:瀏覽器提示使用者,使用者透過觸摸指紋或插入硬體金鑰等方式與驗證器(Authenticator)互動。
- 硬體簽署:驗證器在內部安全地生成一對新的公私鑰,並建立一份「證明 (Attestation)」,然後回傳給瀏覽器。
- 回傳後端:瀏覽器將包含新公鑰與證明的憑證,打包送回我們的後端伺服器。
- 後端驗證:這是最核心的環節。後端必須嚴格驗證這份證明的真實性與完整性,確保它符合挑戰碼、來源網域等所有安全要求。
- 完成註冊:驗證通過後,後端儲存公鑰,並告知前端註冊成功。
接下來,一起聚焦在開發者最需要親手打造的環節:第二步的「後端出題」與第七步的「後端驗證」。
PublicKeyCredentialCreationOptions
一切始於我們的後端伺服器(RP)產生一份「註冊挑戰書」,也就是 PublicKeyCredentialCreationOptions
物件。這份文件詳細定義了本次註冊儀式的規則與參數,是後端要求瀏覽器和驗證器的配合規則。
PublicKeyCredentialCreationOptions
後端生成範例 (Node.js):
import { randomBytes } from 'crypto';
function generateRegistrationOptions(username, userId) {
// `RP` (Relying Party) 物件
const rp = {
id: 'your-website-domain.com', // **網站的有效域名**
name: 'FIDO零信任鐵人賽',
};
// `user` 物件:代表正在註冊的使用者
const user = {
id: userId, // 使用者在系統中的唯一 ID,必須是二進位格式 (ArrayBuffer)
name: username,
};
// `challenge`:一個長度至少為 16 bytes 的隨機、不可預測的字串
// 這是防止「重放攻擊 (Replay Attack)」的核心機制
const challenge = randomBytes(32); // 使用加密的隨機數生成器
// `pubKeyCredParams`:告知驗證器我們接受哪些種類的加密演算法
// -7 (ES256) 和 -257 (RS256) 是最常見且推薦的選項
const pubKeyCredParams = [
{ type: 'public-key', alg: -7 },
{ type: 'public-key', alg: -257 },
];
return {
rp,
user,
challenge,
pubKeyCredParams,
timeout: 60000, // 允許使用者操作的逾時時間 (毫秒)
attestation: 'direct', // 證明類型,'direct' 表示從驗證器獲取最直接的證明
};
}
// 在實際傳送給前端前,二進位的 challenge 和 user.id
// 通常會被轉換成 Base64URL 格式,前端接收後再轉回 ArrayBuffer。
前端拿到這份 options
後,會呼叫 navigator.credentials.create()
,觸發瀏覽器與驗證器的互動。使用者完成生物辨識後,前端會得到一個 PublicKeyCredential
物件,並將其回傳。
現在,好戲登場了。後端伺服器收到了憑證,也就是 AuthenticatorAttestationResponse
。這是整個註冊流程中最複雜、但也最關鍵的一步:驗證憑證的真實性。
後端驗證 AuthenticatorAttestationResponse
的核心步驟:
這是一個嚴謹的驗證鏈,任何一步失敗,都必須拒絕此次註冊。
驗證 clientDataJSON
:核對基礎資訊
clientDataJSON
(一個二進位 Buffer)解析為 JSON 物件。type
:必須是 webauthn.create
。確保這是一個註冊請求,而不是登入請求。challenge
:將 clientDataJSON
中的 challenge
(Base64URL 編碼) 與我們當初在 Session 中儲存的 challenge
進行比對。必須完全一致。這是防止跨站請求偽造 (CSRF) 和重放攻擊的關鍵。origin
:必須與我們網站的來源 (e.g., https://your-website-domain.com
) 完全一致。這是 FIDO 協議內建的防釣魚機制,確保憑證是為我們的網站所產生。解碼 attestationObject
:拆解證明封包
attestationObject
是一個採用 CBOR (Concise Binary Object Representation) 格式編碼的二進位資料。我們需要使用專門的函式庫 (如 cbor
for Node.js) 將其解碼。fmt
:證明的格式 (e.g., "packed", "fido-u2f")。authData
:包含了最核心的驗證器數據。attStmt
:包含了關於證明本身的敘述。驗證 authData
:深入驗證器數據
authData
也是一個需要按位元組 (byte) 精確解析的二進位結構。rpIdHash
:取出 authData
的前 32 bytes,這是我們 RP ID (your-website-domain.com
) 的 SHA-256 雜湊值。我們必須在後端用同樣的演算法計算一次 RP ID 的雜湊,並確保兩者完全匹配。authData
的第 33 byte 是一個旗標位元組。
UP
(User Present) 位元是否為 1,確保使用者在場。UV
(User Verified) 位元是否為 1,確保使用者通過了生物辨識或 PIN 碼驗證。提取憑證公鑰並儲存
authData
中安全地提取出 credentialId
(憑證 ID)和 credentialPublicKey
(使用者公鑰)。userId
credentialId
(未來用於登入時識別是哪個驗證器)publicKey
(CBOR 或 PEM 格式)signCount
(簽章計數器,初始為 0,登入時會用到)transports
(驗證器類型,如 "internal", "usb", "nfc")當後端伺服器走完了上述所有嚴謹的驗證步驟,並成功將使用者的公鑰、憑證 ID 等資訊安全地存入資料庫後,這份「密碼學契約」便正式生效了。
到現在我們正式完成整個註冊流程:
現在新註冊的裝置,就是使用者未來作為驗證登入的「數位鑰匙」。
今天我們一起深入協議層,不僅要瞭解註冊儀式的完整流程,更掌握後端建構 Options
的方法,以及驗證 Response
的每一個關鍵步驟。這個過程雖然複雜,但正是這些環環相扣的驗證,才構建了 FIDO/WebAuthn 堅不可摧的安全性。
現在,使用者與我們的服務之間已經建立了一份基於公鑰密碼學的信任。
明天,我們將探索如何使用這份信任契約,解構 WebAuthn 的第二場核心儀式——驗證 (Authentication),看看登入流程是如何做到比註冊更快速,同時又保持同等級別的安全性的。