在昨天的註冊儀式中,我們的後端伺服器(RP)與使用者的驗證器簽訂了一份基於公鑰密碼學的信任契約。這份契約的核心是我們安全地儲存了使用者的公鑰。
今天,我們將進入第二場核心儀式——驗證 (Authentication/Assertion)。如果說註冊是「建立信任」,那麼驗證就是「行使信任」。這個過程快如閃電,卻又堅如磐石。使用者不再需要輸入任何密碼,只需透過一個簡單的生物辨識動作,就能向伺服器提交一份不可偽造的「通行證」,證明「我就是我」。
與註冊相比,驗證的流程更加直接,因為信任的基礎已經建立。我們不再需要交換公鑰,只需要驗證一個簽章。
PublicKeyCredentialRequestOptions
與註冊類似,驗證流程也由後端發起一個「挑戰」開始。但這次的「挑戰書」內容有所不同,它更像是一份「通關密語」的請求。
一個典型的 PublicKeyCredentialRequestOptions
後端生成範例 (Node.js):
import { randomBytes } from 'crypto';
async function generateAuthenticationOptions(user) {
// 從資料庫中讀取這位使用者先前註冊的所有憑證
const userCredentials = await database.getCredentialsByUserId(user.id);
// `challenge`:同樣地,必須是一個每次登入都不同的隨機、不可預測的字串
const challenge = randomBytes(32);
return {
challenge,
// `allowCredentials`:指定我們只接受哪些憑證來進行登入
// 這是為了告訴瀏覽器,這位使用者曾經用哪些驗證器註冊過
// 瀏覽器會優先嘗試啟動這些指定的驗證器
allowCredentials: userCredentials.map(cred => ({
type: 'public-key',
id: cred.credentialId, // 從資料庫讀出的憑證 ID
transports: cred.transports, // ['internal', 'usb', 'nfc', 'ble']
})),
timeout: 60000,
userVerification: 'preferred', // 'preferred' 表示我們希望進行使用者驗證(如指紋),但如果驗證器不支援,也可以接受僅使用者在場(按一下)
};
}
// 同樣地,challenge 和 allowCredentials 中的 id 在傳送給前端前
// 需要轉換成 Base64URL 格式。
前端拿到 options
後,呼叫 navigator.credentials.get()
。驗證器會使用儲存在安全晶片中的私鑰,對後端發來的 challenge
和其他數據進行簽章,產生一份「斷言 (Assertion)」。
現在,輪到後端執行本次儀式最關鍵的任務:核對這份簽章過的通行證。這一步的核心是:「用我們儲存的公鑰,能否成功解開這份由私鑰加密的簽章?」
後端驗證 AuthenticatorAssertionResponse
的核心步驟:
查找憑證:從前端傳來的 credentialId
,去資料庫中找到對應的憑證紀錄,取得儲存的公鑰 (Public Key) 和簽章計數器 (Sign Count)。如果找不到,驗證失敗。
驗證 clientDataJSON
:
type
:必須是 webauthn.get
。challenge
:必須與 Session 中儲存的 challenge
完全一致。origin
:必須與我們的網站來源完全一致。驗證 authData
:
rpIdHash
:與註冊時一樣,確保是針對我們網站的請求。UP
(使用者在場) 為 1。如果我們的 userVerification
策略設為 required
,則還需檢查 UV
(使用者已驗證) 也為 1。驗證簽章計數器 (signCount
) - 防止憑證複製攻擊
authData
中包含一個 32 位元的 signCount
,每次簽章後,驗證器內部的計數器都會加一。signCount
必須大於 我們資料庫中為該憑證儲存的 signCount
。signCount
,他就可以用這個複製的驗證器在另一個地方登入。但有了 signCount
,即使他複製了憑證,他發出的第一個簽章的 signCount
會和你合法登入後的 signCount
相同或更小。我們的伺服器會因為「計數器沒有增加」而拒絕這次登入,從而有效地阻止了這種攻擊。驗證簽章 (signature
)
authData
和 clientDataJSON
的 SHA-256 雜湊值拼接在一起,形成一個「待簽章的數據 payload」。signature
是否是對上述 payload
的有效簽章。更新簽章計數器
signCount
更新為本次從 authData
中收到的新值。這確保了下一次登入時,計數器驗證依然有效。當後端伺服器成功驗證了簽章,並更新了簽章計數器後,就等於確認了使用者持有有效的私鑰,身分驗證宣告成功。
此刻,登入流程的最後一哩路就此打通:
從使用者輕觸指紋,到看見登入後的頁面,整個過程可能不到一秒。相較於傳統輸入密碼、等待 OTP 簡訊的流程,FIDO/WebAuthn 不僅在安全性上是跨世代的躍進,在使用者體驗上更是無可比擬的提升。
我們完成了!通過解構驗證儀式,我們看到 WebAuthn 如何在不傳輸任何秘密的情況下,實現快速且極度安全的登入。challenge
確保了每次登入的唯一性,origin
綁定防範了釣魚,而 signCount
機制則為我們的憑證安全提供了更深一層的保障。
至此,我們已經徹底掌握了 WebAuthn 協議中最核心的註冊與驗證兩大流程的理論與實踐細節。我們已經具備了所有必要的知識儲備。
從明天開始,我們將從抽象的協議理論轉向具體的工程實踐。我們將開始繪製我們專案的系統架構圖,設計資料庫結構,並引入威脅模型來審視我們的設計,為第二週的後端實作鋪平道路。