生物辨識:為什麼伺服器只存一把公鑰

whats-new · 學習文章 · 2026-09-19 14:04
Chance Lu

付款密碼可以雜湊,那指紋和臉呢?很多人的第一直覺是「也雜湊一下存起來吧」。這個直覺會把整件事帶往錯誤的方向——因為伺服器從頭到尾就不該拿到任何生物特徵。

🖐️

正確答案出乎意料地簡單:伺服器只存一把公鑰和一個 credential id。這張表就算整個外洩,攻擊者拿到的是「印鑑」,刻不出「印章」。

🎯

讀完這篇,你要能用自己的話回答:為什麼樣板不離開手機、每次的隨機 challenge 在防什麼、FIDO2 是機制而 WebAuthn 只是其中一種介面、自己用原生 API 刻會少掉什麼,以及公鑰為什麼原樣存。

✎就愛考你

這幾題就是本文的重點,也是讀完你該能用自己的話答出來的問題。先想想現在答不答得出來,然後帶著它們往下讀——遇到答案所在的段落就特別留意;讀完再回來點「看答案」對照。

  1. 生物辨識登入時,伺服器資料庫裡到底存了什麼?外洩後攻擊者能拿來做什麼?看答案收合
    只存一把公鑰和一個 credential id;外洩後攻擊者拿到的是「印鑑」,刻不出「印章」。
    • 指紋/臉的樣板永遠不離開手機的安全晶片,伺服器從頭到尾沒看過。
    • 私鑰也鎖在晶片裡,只有生物辨識通過時才能拿來簽名。
    • 公鑰的用途只有一個:驗證簽章。拿到它偽造不出簽章。
  2. 伺服器每次驗證都丟一個新的隨機 challenge,是在防什麼?如果重複用同一個會怎樣?看答案收合
    防重放攻擊:讓「錄下來的一次成功簽章」下次就沒用。
    • 裝置簽的是「這次的 challenge」;下次 challenge 不同,舊簽章就對不上。
    • WebAuthn 要求 challenge 由伺服器隨機產生、必須有足夠隨機性(MUST),並建議至少 16 bytes(SHOULD)。
    • 重複用同一個 = 攔到一次簽章就能一直登入。
  3. 同樣是「公鑰挑戰-回應」,走 WebAuthn API 跟自己用手機原生 API 刻,差在哪?看答案收合
    機制一樣,但走標準你「免費」拿到一整包檢查;自己刻,那包東西要自己補或自己放棄。
    • 走標準免費得到:RP ID 綁定、origin 檢查、標準 attestation、authenticatorData 裡的 UV/UP 旗標與 signCount。
    • 自己刻:伺服器手上就只剩「用公鑰對一段隨機 challenge 驗簽」這一件事。
  4. PIN 一定要雜湊才能存,公鑰卻是原樣存。為什麼公鑰不用雜湊?看答案收合
    因為公鑰本來就不是秘密,而且雜湊過就驗不了簽章了。
    • PIN 是秘密,驗證只需要「一不一樣」,所以能壞掉就壞掉。
    • 公鑰是拿來做數學運算的(橢圓曲線上的一個點);雜湊會把它變成一串沒有結構的 bytes,再也算不動驗簽。
    • 公鑰欄位要的不是保密,是防竄改。

1印章與印鑑卡:一對鑰匙怎麼分工

一句話:私鑰是印章、公鑰是印鑑卡;印章留在手機晶片,印鑑卡交給伺服器。

先把新名詞拆開:非對稱金鑰對就是一對鑰匙,私鑰能「簽名」,公鑰只能「驗證那個簽名是不是私鑰簽的」。

🧧
私鑰=印章

在手機的安全晶片裡產生,出不來。而且晶片被設定成「生物辨識通過才准用這把私鑰」。

🗂️
公鑰=銀行的印鑑卡

連同一個 credential id(識別「哪一把」)送到伺服器存起來。拿印鑑卡只能核對,沒辦法反刻出章。

指紋在這裡只是解鎖印章的開關,從頭到尾沒有被送到任何地方。三份權威來源講的是同一件事:

🍎

Apple 平台安全指南:Face ID/Touch ID 樣板由 Secure Enclave 加密儲存,「這些資料永不離開裝置,不會送給 Apple,也不包含在備份裡」;比對也在 Secure Enclave 內完成。

🌐

W3C WebAuthn §14.3:生物辨識由裝置端的驗證器內部完成,「生物資料不會揭露給伺服器」;伺服器只看得到一個「已驗過人」的旗標。

📏

NIST SP 800-63B §3.2.3:生物比對可在裝置端或中央做,但「中央驗證方遭大規模攻擊的風險更高,比對應該在本機執行」。

所以伺服器不存指紋不是「懶得存」,是架構上就不該拿到——拿到就變成大規模外洩的單點。

一個必須點名的缺口:驗簽只能證明「這個簽章是那把私鑰簽的」,證明不了「那把私鑰真的鎖在晶片裡、真的被指紋守著」。要驗金鑰的「出身」得靠 attestation,這是系列第三篇的主題。
💡

這段一句話:伺服器不驗「你是誰」,只驗「你手上那把只有本人能解鎖的私鑰,能不能簽出對的簽章」。

2一次一道考題:challenge 與伺服器的四項檢查

一句話:每次登入伺服器出一道新考題(challenge),裝置用私鑰作答,伺服器用公鑰改考卷。

下面這張圖是每一次生物辨識付款背後發生的事。物件都不動,只有「當下在做哪一步」的光暈會依序點亮;點下方按鈕可下載 SVG。

伺服器 手上只有:公鑰 + credential id 手機上的 App 只是傳話筒,碰不到私鑰 安全晶片 Secure Enclave / StrongBox ① 丟一個隨機 challenge 建議至少 16 bytes、只用一次、伺服器先記著 ② 請晶片用私鑰簽這份 challenge BiometricPrompt + CryptoObject/SecKey 簽章 ③ 指紋/臉在晶片內比對 通過了,晶片才拿私鑰簽名 樣板、私鑰都不離開晶片 ④ 回傳簽名(+authenticatorData、clientDataJSON) App 只是轉交,看得到但改不了 ⑤ 用存的公鑰驗簽 再比對 challenge 是不是剛發的那個 資料庫外洩 → 攻擊者只拿到「印鑑」(公鑰),刻不出「印章」(私鑰)

伺服器到底在檢查什麼

對照 W3C WebAuthn 的規定,伺服器(規範裡叫 Relying Party/RP,就是你的伺服器)在驗證階段的核心檢查有四項:

  1. challenge 一致:回傳的 challenge 必須等於剛剛發出去的那個。
  2. 來源正確:origin 是自己預期的、rpIdHash 是自己 RP ID(服務網域名)的 SHA-256。
  3. 簽章有效:用註冊時存的公鑰,驗證簽章確實是對該筆資料簽的。
  4. 簽章計數(signCount)沒有倒退——倒退可能代表私鑰被複製。

challenge 自己的規矩(WebAuthn §13.4.3):必須(MUST)由伺服器在自己信任的環境隨機產生、必須有足夠的隨機性讓人猜不到;規範建議(SHOULD)至少 16 bytes。

另外三條來自 Google 的 passkey 伺服器端指南:登入後(成功或失敗)立即捨棄、不可重複接受同一個 challenge、過一段時間也要作廢(該文建議 timeout 預設 5 分鐘、最多 10 分鐘)。

為什麼這幾條加起來就擋掉重放?因為攻擊者側錄到的簽章,是對那一題簽的。下次伺服器出新題目,舊答案對不上;而那一題早就作廢了。

WebAuthn 的原話是:回來的值不符就一律拒絕——「容忍不一致會破壞整個協定的安全性」。
💡

這段一句話:challenge = 一次性的考題。一次性是它唯一的價值,重複用就等於沒有。

3FIDO2 是機制,WebAuthn 只是其中一種介面

一句話:「公鑰挑戰-回應」是機制,WebAuthn 是實作這個機制的一組 API——機制可以脫離那組 API 單獨存在。

先把三個名字釘好,都只是名字,不難:

換句話說,WebAuthn 規定的是「網頁怎麼叫用這套機制」。那如果你不是網頁呢?

同一套機制,也可以用原生 API 直接刻

手機 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 刻同一套機制完全可行,但那一整包檢查要自己補或自己放棄。

4標準版與自刻版,簽的不是同一串 bytes

一句話:兩條路的「被簽資料」根本不同——搞混的後果是簽章永遠驗不過。

標準 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 與 RP ID:自刻版沒有對應物

💡

這段一句話:標準版簽的是 authenticatorData || SHA-256(clientDataJSON),自刻版簽的是 challenge 本身。先確認你在哪條路上,再寫驗簽。

想看程式碼?.NET 伺服器端驗簽範例(含 iOS 公鑰格式的坑)

假設註冊時你已經把公鑰轉成 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 公鑰,各平台吐出來的打包格式不一樣:

  • .NET 伺服器端要的是 SPKI 的 DER 編碼——就是 ImportSubjectPublicKeyInfo 吃的東西。
  • Android:Java 的 PublicKey.getEncoded() 對公鑰的主要編碼格式就是 "X.509",也就是 SubjectPublicKeyInfo。直接丟給 .NET 就能用。
  • iOS: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 再存,驗簽程式就只剩一種路徑。

5公鑰為什麼是「原樣存」,不像 PIN 那樣雜湊

一句話:秘密才需要壞掉;公鑰不是秘密,而且壞掉就不能算了。

第一個理由是分類問題:PIN 是秘密,公鑰不是。FIDO 的說法是,線上服務從頭到尾只看得到公鑰和簽章——沒有秘密,自然沒有「要不要壞掉」的問題。

第二個理由更硬,純技術:雜湊過就驗不了簽章了。

驗簽不是「比對兩串東西一不一樣」,而是拿公鑰去做一段數學運算——ES256 的公鑰其實是 P-256 曲線上的一個座標點 (X, Y),驗簽演算法要用到這個結構。

雜湊會把它輾成一串沒有結構、也算不回去的 bytes,運算就做不下去了。

那公鑰欄位就可以隨便放嗎?也不是——它要的保護不是機密性,是完整性:

👀
被看到:還好

攻擊者拿到公鑰偽造不出簽章(沒有私鑰),傷害有限。

✏️
被改掉:這才是要防的

誰能把你資料庫裡的公鑰換成他自己的,他就能用自己的私鑰堂堂正正登入。(這是從「驗簽只信任那一欄」推出來的結論,不是引用哪條規範。)

💡

這段一句話:公鑰要防的是被改,不是被看——這跟 PIN 的威脅模型正好相反。

6澄清:「生物辨識 = FIDO + salt」哪裡對、哪裡不對

一句話:「FIDO」對,「salt」該換成「challenge」。

兩個東西都是「隨機值」,功能卻完全不同:

salt(鹽)challenge(一次性隨機數)
屬於哪套機制密碼雜湊儲存公開金鑰簽章驗證
什麼時候產生設定密碼時產生一次,之後固定每次驗證都產生新的
存多久永久,跟雜湊放同一列只活這一次流程,用完即丟
防什麼對照表攻擊、一次撞多人重放攻擊
誰用它伺服器算雜湊時混進去裝置拿私鑰簽它,伺服器比對它

那規範裡有沒有真的出現「salt」、可能被記成這樣?我翻了 W3C WebAuthn Level 3 全文:字面上出現很多次,但絕大多數在附錄的測試向量裡。

規範性正文裡,salt 只跟兩個主題有關,都不是登入驗證本身:

另一個更可能的記混來源就是 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 只用一次」,不是來自把什麼東西藏好。

🔗這系列的另外兩篇

📚參考來源