付款密碼可以雜湊,那指紋和臉呢?很多人的第一直覺是「也雜湊一下存起來吧」。這個直覺會把整件事帶往錯誤的方向——因為伺服器從頭到尾就不該拿到任何生物特徵。
正確答案出乎意料地簡單:伺服器只存一把公鑰和一個 credential id。這張表就算整個外洩,攻擊者拿到的是「印鑑」,刻不出「印章」。
讀完這篇,你要能用自己的話回答:為什麼樣板不離開手機、每次的隨機 challenge 在防什麼、FIDO2 是機制而 WebAuthn 只是其中一種介面、自己用原生 API 刻會少掉什麼,以及公鑰為什麼原樣存。
這幾題就是本文的重點,也是讀完你該能用自己的話答出來的問題。先想想現在答不答得出來,然後帶著它們往下讀——遇到答案所在的段落就特別留意;讀完再回來點「看答案」對照。
authenticatorData 裡的 UV/UP 旗標與 signCount。一句話:私鑰是印章、公鑰是印鑑卡;印章留在手機晶片,印鑑卡交給伺服器。
先把新名詞拆開:非對稱金鑰對就是一對鑰匙,私鑰能「簽名」,公鑰只能「驗證那個簽名是不是私鑰簽的」。
在手機的安全晶片裡產生,出不來。而且晶片被設定成「生物辨識通過才准用這把私鑰」。
連同一個 credential id(識別「哪一把」)送到伺服器存起來。拿印鑑卡只能核對,沒辦法反刻出章。
指紋在這裡只是解鎖印章的開關,從頭到尾沒有被送到任何地方。三份權威來源講的是同一件事:
Apple 平台安全指南:Face ID/Touch ID 樣板由 Secure Enclave 加密儲存,「這些資料永不離開裝置,不會送給 Apple,也不包含在備份裡」;比對也在 Secure Enclave 內完成。
W3C WebAuthn §14.3:生物辨識由裝置端的驗證器內部完成,「生物資料不會揭露給伺服器」;伺服器只看得到一個「已驗過人」的旗標。
NIST SP 800-63B §3.2.3:生物比對可在裝置端或中央做,但「中央驗證方遭大規模攻擊的風險更高,比對應該在本機執行」。
所以伺服器不存指紋不是「懶得存」,是架構上就不該拿到——拿到就變成大規模外洩的單點。
一個必須點名的缺口:驗簽只能證明「這個簽章是那把私鑰簽的」,證明不了「那把私鑰真的鎖在晶片裡、真的被指紋守著」。要驗金鑰的「出身」得靠 attestation,這是系列第三篇的主題。
這段一句話:伺服器不驗「你是誰」,只驗「你手上那把只有本人能解鎖的私鑰,能不能簽出對的簽章」。
一句話:每次登入伺服器出一道新考題(challenge),裝置用私鑰作答,伺服器用公鑰改考卷。
下面這張圖是每一次生物辨識付款背後發生的事。物件都不動,只有「當下在做哪一步」的光暈會依序點亮;點下方按鈕可下載 SVG。
對照 W3C WebAuthn 的規定,伺服器(規範裡叫 Relying Party/RP,就是你的伺服器)在驗證階段的核心檢查有四項:
origin 是自己預期的、rpIdHash 是自己 RP ID(服務網域名)的 SHA-256。challenge 自己的規矩(WebAuthn §13.4.3):必須(MUST)由伺服器在自己信任的環境隨機產生、必須有足夠的隨機性讓人猜不到;規範建議(SHOULD)至少 16 bytes。
另外三條來自 Google 的 passkey 伺服器端指南:登入後(成功或失敗)立即捨棄、不可重複接受同一個 challenge、過一段時間也要作廢(該文建議 timeout 預設 5 分鐘、最多 10 分鐘)。
為什麼這幾條加起來就擋掉重放?因為攻擊者側錄到的簽章,是對那一題簽的。下次伺服器出新題目,舊答案對不上;而那一題早就作廢了。
WebAuthn 的原話是:回來的值不符就一律拒絕——「容忍不一致會破壞整個協定的安全性」。
這段一句話:challenge = 一次性的考題。一次性是它唯一的價值,重複用就等於沒有。
一句話:「公鑰挑戰-回應」是機制,WebAuthn 是實作這個機制的一組 API——機制可以脫離那組 API 單獨存在。
先把三個名字釘好,都只是名字,不難:
換句話說,WebAuthn 規定的是「網頁怎麼叫用這套機制」。那如果你不是網頁呢?
手機 App 完全可以不經過 WebAuthn,直接用 iOS Secure Enclave/Android Keystore 的原生 API 做出同一個機制:裝置產金鑰對 → 生物辨識才准用私鑰 → 伺服器發 challenge → 裝置簽 → 伺服器驗簽。
這條路是成立的,密碼學上就是同一件事。但代價要看清楚:WebAuthn 幫你做掉的一整包檢查,這條路上一個都沒有。
| 能力 | 走 WebAuthn/passkey | 用原生 API 自己刻 |
|---|---|---|
| RP ID 綁定 (憑證綁死某個網域,換個站就用不了) | 內建 規範明訂驗證器會檢查請求的 RP ID「exactly matches」 | 沒有 晶片不知道這段 bytes 要簽給誰,防釣魚得靠 App 自己只連自家伺服器 |
| origin 檢查 | 內建 client 把 origin 寫進 clientDataJSON,伺服器逐一比對 | 沒有 連 clientDataJSON 這個東西都不存在 |
authenticatorData( rpIdHash、UP/UV 旗標、signCount) | 內建 每次驗證都拿得到 | 沒有 伺服器收不到「裝置說它驗過人了」這個訊號 |
| attestation(證明金鑰出身) | 有標準格式 註冊時一起回來 | 要自己接 Android Key Attestation/Apple App Attest,不接就等於沒有 |
| 跨裝置同步 | 可以 規範連備份資格/備份狀態旗標都定義好了 | 不可能 Secure Enclave 金鑰連匯出都做不到,換手機就是重新註冊 |
| 伺服器要做的事 | 一串檢查:challenge、origin、rpIdHash、UP/UV、signCount、簽章 | 基本上只剩一件:用存的公鑰對一段 challenge 驗簽 |
最後一列看起來像好消息(伺服器超簡單),其實是這條路最大的風險所在:伺服器少掉的那些檢查並沒有消失,只是變成「你相信 App 端有做」。
這段一句話:FIDO2 是機制,WebAuthn 是網頁用的那個介面。用原生 API 刻同一套機制完全可行,但那一整包檢查要自己補或自己放棄。
一句話:兩條路的「被簽資料」根本不同——搞混的後果是簽章永遠驗不過。
| 標準 WebAuthn/passkey | 原生 API 自刻版 | |
|---|---|---|
| 裝置簽的是什麼 | authenticatorData || SHA-256(clientDataJSON) | 伺服器發的那段 challenge 本身(或你自訂的一段結構) |
| challenge 在哪 | 藏在 clientDataJSON 裡,不是直接被簽的那串 | 就是被簽的那串 |
| 伺服器怎麼驗 | 拆 clientDataJSON → 比 challenge/origin → 比 rpIdHash → 看 UP/UV → 比 signCount → 最後才驗簽 | 用公鑰對 challenge 驗簽,就這樣 |
Google 的 passkey 伺服器端指南把標準版講得很直白:驗證器簽的是「authenticatorData 與『包含挑戰的 clientDataJSON』之雜湊的串接」。
驗簽需要三樣東西——簽章、公鑰、原始被簽資料,外加知道它用的是哪個演算法。
拿標準 library 去驗自刻的簽章,永遠驗不過——因為 library 會自己去組 authenticatorData || hash。反過來,也別以為自刻版有 authenticatorData 給你的那些保證。
signCount:規範原文說它的用途是「aid Relying Parties in detecting cloned authenticators」——幫伺服器發現私鑰被複製(正常只會遞增,倒退就可疑)。自刻版沒有這個欄位,就沒有這個偵測能力。緩解的理由是晶片私鑰本來就匯不出來,複製門檻很高(這句是推論,不是規範保證)。這段一句話:標準版簽的是 authenticatorData || SHA-256(clientDataJSON),自刻版簽的是 challenge 本身。先確認你在哪條路上,再寫驗簽。
假設註冊時你已經把公鑰轉成 X.509 SubjectPublicKeyInfo(SPKI)DER 格式存進資料庫。WebAuthn 原生給的是 COSE 格式——COSE 與 SPKI-DER 只是兩種打包公鑰的格式,內容是同一把鑰匙,轉換交給 library。ES256 的簽章在 WebAuthn §6.5.5 明定為 ASN.1 DER。
// .NET 5+:ECDsa.VerifyData(..., DSASignatureFormat) 需 .NET 5;ImportSubjectPublicKeyInfo 需 .NET Core 3.0+
using System.Security.Cryptography;
using System.Text.Json;
public static class BiometricVerifier
{
// ① 發 challenge:隨機、至少 16 bytes(WebAuthn 建議),存起來、用一次就丟
public static byte[] NewChallenge() => RandomNumberGenerator.GetBytes(32);
// ② 收到裝置回覆後驗簽
public static bool VerifyAssertion(
byte[] storedPublicKeySpkiDer, // 註冊時存的公鑰(SubjectPublicKeyInfo, DER)
byte[] authenticatorData, // 裝置回傳
byte[] clientDataJson, // 裝置回傳(含 challenge、origin、type)
byte[] signatureDer, // 裝置回傳(ES256 → ASN.1 DER)
byte[] expectedChallenge) // 剛剛發出去、暫存在伺服器的那個
{
// (a) challenge 必須等於我們剛發的那個 —— 防重放
using var doc = JsonDocument.Parse(clientDataJson);
byte[] gotChallenge = Base64UrlDecode(doc.RootElement.GetProperty("challenge").GetString()!);
if (!CryptographicOperations.FixedTimeEquals(gotChallenge, expectedChallenge)) return false;
// 實務上還要驗 type == "webauthn.get"、origin、rpIdHash、UV 旗標、signCount
// (b) 被簽的資料 = authenticatorData || SHA-256(clientDataJSON)
byte[] clientDataHash = SHA256.HashData(clientDataJson);
byte[] signed = new byte[authenticatorData.Length + clientDataHash.Length];
authenticatorData.CopyTo(signed, 0);
clientDataHash.CopyTo(signed, authenticatorData.Length);
// (c) 用存的公鑰驗簽(ES256 = ECDSA P-256 + SHA-256)
using var ecdsa = ECDsa.Create();
ecdsa.ImportSubjectPublicKeyInfo(storedPublicKeySpkiDer, out _);
return ecdsa.VerifyData(signed, signatureDer, HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence);
}
static byte[] Base64UrlDecode(string s)
{
string b64 = s.Replace('-', '+').Replace('_', '/');
return Convert.FromBase64String(b64.PadRight(b64.Length + (4 - b64.Length % 4) % 4, '='));
}
}
公鑰格式的跨平台坑。同一把 P-256 公鑰,各平台吐出來的打包格式不一樣:
ImportSubjectPublicKeyInfo 吃的東西。PublicKey.getEncoded() 對公鑰的主要編碼格式就是 "X.509",也就是 SubjectPublicKeyInfo。直接丟給 .NET 就能用。SecKeyCopyExternalRepresentation 給的是 ANSI X9.63 的 04 || X || Y,P-256 剛好 65 bytes,沒有「這是哪條曲線」的標頭,直接丟給 .NET 會炸。乾淨的解法是伺服器端直接從 65 bytes 組出公鑰,因為 X 和 Y 就明擺在那裡:
// ECDsa.Create(ECParameters) 是 .NET Core 2.0+ 的標準 API。
static ECDsa FromX963P256(byte[] x963)
{
if (x963.Length != 65 || x963[0] != 0x04)
throw new ArgumentException("不是未壓縮的 P-256 公鑰點");
var p = new ECParameters
{
Curve = ECCurve.NamedCurves.nistP256,
Q = new ECPoint { X = x963[1..33], Y = x963[33..65] }
};
p.Validate(); // 順手檢查這個點真的在曲線上
return ECDsa.Create(p);
}
// 之後要存成通用格式也很容易:ecdsa.ExportSubjectPublicKeyInfo()
實務建議是在伺服器入口就正規化:不管 App 傳來的是 SPKI 還是裸的 X9.63,一律轉成 SPKI DER 再存,驗簽程式就只剩一種路徑。
一句話:秘密才需要壞掉;公鑰不是秘密,而且壞掉就不能算了。
第一個理由是分類問題:PIN 是秘密,公鑰不是。FIDO 的說法是,線上服務從頭到尾只看得到公鑰和簽章——沒有秘密,自然沒有「要不要壞掉」的問題。
第二個理由更硬,純技術:雜湊過就驗不了簽章了。
驗簽不是「比對兩串東西一不一樣」,而是拿公鑰去做一段數學運算——ES256 的公鑰其實是 P-256 曲線上的一個座標點 (X, Y),驗簽演算法要用到這個結構。
雜湊會把它輾成一串沒有結構、也算不回去的 bytes,運算就做不下去了。
那公鑰欄位就可以隨便放嗎?也不是——它要的保護不是機密性,是完整性:
攻擊者拿到公鑰偽造不出簽章(沒有私鑰),傷害有限。
誰能把你資料庫裡的公鑰換成他自己的,他就能用自己的私鑰堂堂正正登入。(這是從「驗簽只信任那一欄」推出來的結論,不是引用哪條規範。)
這段一句話:公鑰要防的是被改,不是被看——這跟 PIN 的威脅模型正好相反。
一句話:「FIDO」對,「salt」該換成「challenge」。
兩個東西都是「隨機值」,功能卻完全不同:
| salt(鹽) | challenge(一次性隨機數) | |
|---|---|---|
| 屬於哪套機制 | 密碼雜湊儲存 | 公開金鑰簽章驗證 |
| 什麼時候產生 | 設定密碼時產生一次,之後固定 | 每次驗證都產生新的 |
| 存多久 | 永久,跟雜湊放同一列 | 只活這一次流程,用完即丟 |
| 防什麼 | 對照表攻擊、一次撞多人 | 重放攻擊 |
| 誰用它 | 伺服器算雜湊時混進去 | 裝置拿私鑰簽它,伺服器比對它 |
那規範裡有沒有真的出現「salt」、可能被記成這樣?我翻了 W3C WebAuthn Level 3 全文:字面上出現很多次,但絕大多數在附錄的測試向量裡。
規範性正文裡,salt 只跟兩個主題有關,都不是登入驗證本身:
salt1/salt2,底層對應 CTAP2 的 hmac-secret 擴充。另一個更可能的記混來源就是 challenge 本身:「每次一個新的隨機值丟給裝置」聽起來很像鹽,但它是防重放的一次性隨機數。
第三個來源最有力,而且已確認:舊版 NIST SP 800-63B(rev.3,§5.1.1.2)真的把伺服器側密鑰叫做 「secret salt」,rev.4 才改口叫「secret key」。
如果你的記憶來自舊版 NIST 或引用它的教材,「salt」很可能是記混成 pepper 了——而 pepper 是 PIN 那一側的東西,跟 FIDO 無關。
這段一句話:salt(以及舊版 NIST 所謂的 secret salt = pepper)都留在 PIN 那一側;生物辨識這一側的隨機值叫 challenge。
生物辨識這一側,伺服器沒有秘密可存——只有一把公鑰、一個 credential id,和每次臨時出的一道考題。樣板與私鑰都鎖在手機晶片裡;安全性來自「私鑰出不來」+「challenge 只用一次」,不是來自把什麼東西藏好。
authenticatorData 欄位表與 signCount、§10.1.4 prf 擴充的 salt1/salt2、§14.3 生物資料不揭露給 RP)authenticatorData 與 clientDataJSON 雜湊的串接)04 || X || Y)X509EncodedKeySpec(公鑰主要編碼格式為 SubjectPublicKeyInfo)ECDsa.ImportSubjectPublicKeyInfo;ECDsa.VerifyData(含 DSASignatureFormat 多載,.NET 5+);ECDsa.Create(ECParameters)