「簽得出來就等於刷過臉」嗎?這條推論的前提

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

生物辨識付款的設計裡,伺服器只做一件事:用存好的公鑰驗一段簽章。驗過了就放行——因為「能簽出來,就代表使用者剛剛刷了臉」。

這條推論每天被用幾百萬次,但它有一個前提,而且那個前提不在伺服器這邊。

🧧

印章能蓋出跟印鑑相符的章,只證明「蓋章的人手上有那顆章」;證明不了那顆章是從要刷臉才開得了的保險箱裡拿出來的。保險箱有沒有上鎖,是刻章那一刻決定的。

🎯

讀完這篇,你要能用自己的話回答:那兩個「決定一切」的旗標是什麼、不設會怎樣、attestation 補上了哪個洞、為什麼加一張臉金鑰就死、以及兩個平台的行為差在哪。

✎就愛考你

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

  1. App 建金鑰時忘了設「要生物辨識才能用」的旗標,伺服器看得出來嗎?看答案收合
    看不出來——簽章照樣驗得過,但實際上根本沒有任何人臉或指紋被驗過。
    • 伺服器只會算「這個簽章是不是這把公鑰的私鑰簽的」,簽章裡沒有夾帶「剛剛刷過臉」這個資訊。
    • 要伺服器自己確認而不是信任 App,就得做 attestation。
  2. 使用者在手機上新增一張臉,為什麼舊金鑰要「永久」作廢?這在擋誰?看答案收合
    擋的是「別人拿到你的手機和螢幕鎖密碼,把自己的臉加進去,然後用你的帳戶付錢」。
    • 金鑰綁的不是「這個人」,是建立當下那一組生物特徵。
    • 那組東西一變,等於換了一組人,舊鑰匙當然不能再用。
  3. 「晶片拒簽」跟「App 判斷之後決定拒絕」差在哪?為什麼這個差別重要?看答案收合
    差在「判斷權在誰手上」——晶片拒簽的話,改 App 也繞不過去。
    • 金鑰失效時,例外是在初始化簽章那一步就丟出來的,生物辨識畫面根本不會跳。
    • App 只是收到一個錯誤,它沒有「要不要放行」的選擇權。
  4. Android 上,為什麼付款情境一定要指定 Class 3(BIOMETRIC_STRONG)?看答案收合
    因為只有 Class 3 能把簽章動作綁在「這一次生物辨識」上。
    • Class 2(BIOMETRIC_WEAK)官方明說不能傳 CryptoObject,只能退而用「驗過之後 N 秒內有效」的時間窗。
    • 「N 秒內都算數」跟「這一次驗證只授權這一次簽章」的保證強度差很多。

1結論先講:簽章裡沒有「剛剛刷過臉」這件事

一句話:伺服器只驗簽章,它無法從簽章本身看出這把私鑰到底有沒有被生物辨識鎖著。

「每次用都要過生物辨識」是建立金鑰時設定的存取條件,寫死在晶片裡。

它事後改不了——這是好事;但它也不會出現在簽章裡——這就是缺口。

✅
簽章能證明的

「送出這個簽章的人,手上有那把私鑰。」就這樣,沒了。

❌
簽章不能證明的

「這把私鑰鎖在安全晶片裡、而且剛剛被指紋或臉解開過。」這件事伺服器看不到。

補充一個對照:走標準 WebAuthn 的話,伺服器會收到一個 UV(user verified)旗標,那是裝置在說「我驗過人了」。用原生 API 自刻的版本連這個旗標都沒有。

💡

這段一句話:「能簽」證明的是「手上有這把私鑰」,不是「剛剛刷過臉」。後者成不成立,取決於建鑰那一刻。

2那兩個決定一切的旗標

一句話:整條推論的前提,就濃縮在 App 建立金鑰時的那兩行參數裡。

iOS

用 SecAccessControlCreateWithFlags 建立存取控制,帶上 .privateKeyUsage(管「使用這把私鑰」)+ .biometryCurrentSet,再用 kSecAttrTokenIDSecureEnclave 把金鑰產在 Secure Enclave 裡。

Android

在 KeyGenParameterSpec.Builder 上帶 setUserAuthenticationRequired(true) + setInvalidatedByBiometricEnrollment(true)(後者其實預設就是 true)。

Apple 對 biometryCurrentSet 的原文是:「Constraint to access an item with Touch ID for currently enrolled fingers, or from Face ID with the currently enrolled user」。

沒設會怎樣?金鑰照樣產得出來、簽章照樣驗得過、流程從頭到尾綠燈——但實際上沒有任何生物辨識發生過。

手機被解鎖的任何人,甚至 App 自己的一段程式,都能叫晶片簽。

驗收時最該看的一件事:請 App 端出示建立金鑰那幾行程式碼,確認這兩個旗標真的在。這比看 100 行伺服器驗簽邏輯都重要——因為伺服器那邊根本驗不出來。
💡

這段一句話:這兩行參數是整套生物辨識付款唯一的地基,而它躺在 App 的程式碼裡,不在你的伺服器上。

3不想只是「相信 App 端」?那就要 attestation

一句話:attestation 是裝置開給伺服器的證明——「這把鑰匙是在哪種硬體、什麼保護條件下產生的」。

前面說伺服器看不出旗標有沒有設。要讓伺服器自己確認,唯一的辦法是註冊時一併驗證這份證明:

沒做 attestation,就是明明白白地選擇信任 App 端。

這不一定是錯的決定(很多情境可以接受),但它要是一個你知道自己做了的決定,不是不知不覺的漏洞。

💡

這段一句話:沒有 attestation 的架構不是不安全,是把信任邊界劃在 App 端——你要知道自己劃在哪裡。

4加一張臉,這把金鑰就死了

一句話:金鑰綁的不是「這個人」,是「建立當下那一組生物特徵」;那組東西一變,金鑰就永久作廢。

為什麼要這樣設計?Android 的官方說法把動機寫得很白:

Invalidating keys on enrollment of a new biometric or unenrollment of all biometrics improves security by ensuring that an unauthorized person who obtains the password can't gain the use of biometric-authenticated keys by enrolling their own biometric.

翻成人話:擋的是「別人拿到你的手機和螢幕鎖密碼,把自己的臉加進去,然後用你的帳戶付錢」。

新增一張臉,等於換了一組人,舊鑰匙當然不能再用。

下面這張圖畫的是「按下付款之後、簽章回來之前」那零點幾秒。物件都不動,只有「當下在哪一步」的光暈依序點亮;前半圈走條件成立那條路,後半圈走金鑰已作廢那條路。

App 「幫我簽這段 challenge」 私鑰在晶片裡,App 碰不到 安全晶片(Secure Enclave / Android Keystore) ② 先看這把金鑰的「使用條件」 建立時設的:要不要生物辨識?綁的是哪一組生物特徵? 這些條件寫死在晶片裡,App 事後改不了 ③ 條件成立:跳出生物辨識 指紋/臉在晶片內比對,樣板不外流 通過了,晶片才動用私鑰簽名 沒通過=沒簽章,App 什麼都拿不到 ⑤ 生物特徵集合變過 → 金鑰已永久作廢 使用者新增一張臉/一根指紋,這把鑰匙就死了 晶片直接拒簽——連生物辨識畫面都不會跳 這是晶片拒絕,不是 App 自己判斷的 ④ 回傳簽章 伺服器用存的公鑰驗簽 通過 → 放行 ⑥ 回傳錯誤 晶片回「這把金鑰已永久失效」 → App 引導重新註冊新金鑰 ① 條件成立 集合變過

App 不用打 API 就知道金鑰死了

最關鍵的觀念在這裡:「簽不出來」不是 App 檢查了什麼然後決定拒絕,而是晶片拒絕動用那把私鑰。

App 只是收到一個錯誤。這也是為什麼這個機制擋得住惡意的 App 改版——判斷權不在 App 手上。

💡

這段一句話:金鑰作廢是晶片的決定,不是 App 的決定——所以產品上一定要準備「重新註冊」這條退路,別讓使用者卡在「按了沒反應」。

5兩個平台不一樣的地方

一句話:失效條件、可用等級、測試環境,三件事兩個平台都不同,不能寫一套規格通吃。

失效條件:同樣的動作,結果不一定一樣

使用者做了什麼iOS(biometryCurrentSet)Android(setInvalidatedByBiometricEnrollment(true))
新增一張臉/一根指紋作廢 原文:fingers are added… / re-enrolls for Face ID作廢 原文:a new biometric is enrolled
刪除其中一根(還有剩)作廢 原文明列 removed不確定 文件只寫「unenrollment of all biometrics」,照字面讀不會作廢;實機行為我沒有實測,要用請自己驗一次
全部刪光/重新錄作廢作廢
把螢幕鎖關掉不確定 Apple 文件在這條沒有明講作廢 javadoc 用的字是「irreversibly invalidated」

不管細節怎麼差,產品上都要準備同一條退路:金鑰失效了就走一次重新註冊(產新金鑰、上傳新公鑰)。

Android 的門檻:只有 Class 3 才能綁 CryptoObject

Android 把生物辨識分等級,跟加密綁定有關的是這兩個:

🥇
Class 3(BIOMETRIC_STRONG)

可以把 CryptoObject(裡面裝簽章物件)直接傳進 authenticate(),做到「這一次生物辨識,只授權這一次簽章」。

🥈
Class 2(BIOMETRIC_WEAK)

官方文件明說不能傳 CryptoObject;要用金鑰只能改設一段「驗過之後 N 秒內有效」的時間窗。

這代表什麼?有些機型的臉部辨識是 Class 2(硬體達不到 Class 3 的標準),那些機型就只能用指紋來做這件事。

付款、轉帳這類情境的合理要求是:指定 BIOMETRIC_STRONG,不要退讓。

💡

這段一句話:「驗過人 N 秒內都算數」跟「一次驗證只授權一次簽章」是兩種強度——付款情境只接受後者。

給 App 端看的:先跳生物辨識、通過了才簽,兩個平台的寫法
  • Android:Signature.initSign(privateKey) → BiometricPrompt.authenticate(promptInfo, CryptoObject(signature)) → 在 onAuthenticationSucceeded 裡才從 result.cryptoObject.signature 拿回那個簽章物件,update(challenge) 再 sign()。
  • iOS:先 LAContext.evaluateAccessControl(accessControl, operation: .useKeySign, localizedReason:) 驗一次,再把同一個 context 以 kSecUseAuthenticationContext 帶進 SecKeyCreateSignature——不會再跳第二次。Apple 的原文保證:「If this key is specified with a context that has been previously authenticated, the operation will succeed without asking user for authentication」。

工程上的好處很實際:你可以在使用者對著鏡頭的那一兩秒裡,把「跟伺服器要 challenge」的網路往返做掉,不用等驗完才開始連線。(這是流程安排的取捨,不是規範要求。)

6常見的坑

⚠️

App 建金鑰時漏設「要生物辨識才能用」的旗標。簽章照樣驗得過,但全程沒有任何人臉/指紋被驗過——而且伺服器完全看不出來。驗收時第一個該看的就是這兩行。

⚠️

以為「有用安全晶片」就等於「有 attestation」。兩件事:私鑰鎖在晶片是裝置端的事實,attestation 是伺服器能不能驗證這個事實。沒做就是選擇相信 App。

⚠️

Android 退讓到 Class 2。拿不到「一次驗證=一次簽章」的綁定,保證強度直接掉一級。付款情境請指定 BIOMETRIC_STRONG。

⚠️

拿模擬器測生物辨識金鑰。iOS 模擬器上建 Secure Enclave 金鑰會失敗(回 errSecUnimplemented)甚至 crash,一律用實機測。(這條我只查到開發者論壇層級的說法,沒有官方文件明文,標為不確定——但「用實機測」這個結論是安全的。)

⚠️

驗簽失敗時沒分清楚是哪一種失敗。「公鑰對不上」「challenge 過期」「金鑰已作廢」是三件完全不同的事,補救路徑也不同(重試 vs 重新取題 vs 重新註冊)。混成一種,之後 debug 會很痛苦。

⚠️

把生物辨識當「單因子」。NIST 要求它只能搭配實體驗證器(手機)作多因子——正好對應「私鑰鎖在晶片」的設計。別另外設計一個「上傳指紋樣板到伺服器比對」的流程。

💡

這段一句話:這幾個坑的共同點是——它們都不會讓流程變紅燈,全程綠燈,直到出事那天。

★一句話帶走

「簽得出來 = 刷過臉」這條推論,前提整個壓在 App 建鑰時的兩個旗標上;伺服器驗不出來,所以你只有兩個選擇:驗收那幾行程式碼,或做 attestation。另外記得:金鑰綁的是「當下那組生物特徵」,特徵一變就永久作廢——而且是晶片拒簽,不是 App 判斷。

🔗這系列的另外兩篇

📚參考來源