生物辨識付款的設計裡,伺服器只做一件事:用存好的公鑰驗一段簽章。驗過了就放行——因為「能簽出來,就代表使用者剛剛刷了臉」。
這條推論每天被用幾百萬次,但它有一個前提,而且那個前提不在伺服器這邊。
印章能蓋出跟印鑑相符的章,只證明「蓋章的人手上有那顆章」;證明不了那顆章是從要刷臉才開得了的保險箱裡拿出來的。保險箱有沒有上鎖,是刻章那一刻決定的。
讀完這篇,你要能用自己的話回答:那兩個「決定一切」的旗標是什麼、不設會怎樣、attestation 補上了哪個洞、為什麼加一張臉金鑰就死、以及兩個平台的行為差在哪。
這幾題就是本文的重點,也是讀完你該能用自己的話答出來的問題。先想想現在答不答得出來,然後帶著它們往下讀——遇到答案所在的段落就特別留意;讀完再回來點「看答案」對照。
BIOMETRIC_STRONG)?看答案收合BIOMETRIC_WEAK)官方明說不能傳 CryptoObject,只能退而用「驗過之後 N 秒內有效」的時間窗。一句話:伺服器只驗簽章,它無法從簽章本身看出這把私鑰到底有沒有被生物辨識鎖著。
「每次用都要過生物辨識」是建立金鑰時設定的存取條件,寫死在晶片裡。
它事後改不了——這是好事;但它也不會出現在簽章裡——這就是缺口。
「送出這個簽章的人,手上有那把私鑰。」就這樣,沒了。
「這把私鑰鎖在安全晶片裡、而且剛剛被指紋或臉解開過。」這件事伺服器看不到。
補充一個對照:走標準 WebAuthn 的話,伺服器會收到一個 UV(user verified)旗標,那是裝置在說「我驗過人了」。用原生 API 自刻的版本連這個旗標都沒有。
這段一句話:「能簽」證明的是「手上有這把私鑰」,不是「剛剛刷過臉」。後者成不成立,取決於建鑰那一刻。
一句話:整條推論的前提,就濃縮在 App 建立金鑰時的那兩行參數裡。
用 SecAccessControlCreateWithFlags 建立存取控制,帶上 .privateKeyUsage(管「使用這把私鑰」)+ .biometryCurrentSet,再用 kSecAttrTokenIDSecureEnclave 把金鑰產在 Secure Enclave 裡。
在 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 的程式碼裡,不在你的伺服器上。
一句話:attestation 是裝置開給伺服器的證明——「這把鑰匙是在哪種硬體、什麼保護條件下產生的」。
前面說伺服器看不出旗標有沒有設。要讓伺服器自己確認,唯一的辦法是註冊時一併驗證這份證明:
沒做 attestation,就是明明白白地選擇信任 App 端。
這不一定是錯的決定(很多情境可以接受),但它要是一個你知道自己做了的決定,不是不知不覺的漏洞。
這段一句話:沒有 attestation 的架構不是不安全,是把信任邊界劃在 App 端——你要知道自己劃在哪裡。
一句話:金鑰綁的不是「這個人」,是「建立當下那一組生物特徵」;那組東西一變,金鑰就永久作廢。
為什麼要這樣設計?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.
翻成人話:擋的是「別人拿到你的手機和螢幕鎖密碼,把自己的臉加進去,然後用你的帳戶付錢」。
新增一張臉,等於換了一組人,舊鑰匙當然不能再用。
下面這張圖畫的是「按下付款之後、簽章回來之前」那零點幾秒。物件都不動,只有「當下在哪一步」的光暈依序點亮;前半圈走條件成立那條路,後半圈走金鑰已作廢那條路。
KeyPermanentlyInvalidatedException。Signature.initSign(key) 那一步就丟出來的,而生物辨識視窗是之後才叫的——所以畫面根本不會跳。(「prompt 不會跳」這句是從呼叫順序推出來的,官方文件沒有逐字這樣寫。)biometryCurrentSet 的原文是「The item is invalidated if fingers are added or removed for Touch ID, or if the user re-enrolls for Face ID」。LAContext.evaluatedPolicyDomainState 存起來、之後比對;但官方提醒「the value you get doesn't describe the nature of a change」(只知道變了,不知道變什麼),而且不同 process 拿到的值不一樣。版本提醒:這個屬性在 iOS 18 已標示 deprecated,新的對應物是 LAContext.domainState。最關鍵的觀念在這裡:「簽不出來」不是 App 檢查了什麼然後決定拒絕,而是晶片拒絕動用那把私鑰。
App 只是收到一個錯誤。這也是為什麼這個機制擋得住惡意的 App 改版——判斷權不在 App 手上。
這段一句話:金鑰作廢是晶片的決定,不是 App 的決定——所以產品上一定要準備「重新註冊」這條退路,別讓使用者卡在「按了沒反應」。
一句話:失效條件、可用等級、測試環境,三件事兩個平台都不同,不能寫一套規格通吃。
| 使用者做了什麼 | 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 把生物辨識分等級,跟加密綁定有關的是這兩個:
BIOMETRIC_STRONG)
可以把 CryptoObject(裡面裝簽章物件)直接傳進 authenticate(),做到「這一次生物辨識,只授權這一次簽章」。
BIOMETRIC_WEAK)
官方文件明說不能傳 CryptoObject;要用金鑰只能改設一段「驗過之後 N 秒內有效」的時間窗。
這代表什麼?有些機型的臉部辨識是 Class 2(硬體達不到 Class 3 的標準),那些機型就只能用指紋來做這件事。
付款、轉帳這類情境的合理要求是:指定 BIOMETRIC_STRONG,不要退讓。
這段一句話:「驗過人 N 秒內都算數」跟「一次驗證只授權一次簽章」是兩種強度——付款情境只接受後者。
Signature.initSign(privateKey) → BiometricPrompt.authenticate(promptInfo, CryptoObject(signature)) → 在 onAuthenticationSucceeded 裡才從 result.cryptoObject.signature 拿回那個簽章物件,update(challenge) 再 sign()。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」的網路往返做掉,不用等驗完才開始連線。(這是流程安排的取捨,不是規範要求。)
App 建金鑰時漏設「要生物辨識才能用」的旗標。簽章照樣驗得過,但全程沒有任何人臉/指紋被驗過——而且伺服器完全看不出來。驗收時第一個該看的就是這兩行。
以為「有用安全晶片」就等於「有 attestation」。兩件事:私鑰鎖在晶片是裝置端的事實,attestation 是伺服器能不能驗證這個事實。沒做就是選擇相信 App。
Android 退讓到 Class 2。拿不到「一次驗證=一次簽章」的綁定,保證強度直接掉一級。付款情境請指定 BIOMETRIC_STRONG。
拿模擬器測生物辨識金鑰。iOS 模擬器上建 Secure Enclave 金鑰會失敗(回 errSecUnimplemented)甚至 crash,一律用實機測。(這條我只查到開發者論壇層級的說法,沒有官方文件明文,標為不確定——但「用實機測」這個結論是安全的。)
驗簽失敗時沒分清楚是哪一種失敗。「公鑰對不上」「challenge 過期」「金鑰已作廢」是三件完全不同的事,補救路徑也不同(重試 vs 重新取題 vs 重新註冊)。混成一種,之後 debug 會很痛苦。
把生物辨識當「單因子」。NIST 要求它只能搭配實體驗證器(手機)作多因子——正好對應「私鑰鎖在晶片」的設計。別另外設計一個「上傳指紋樣板到伺服器比對」的流程。
這段一句話:這幾個坑的共同點是——它們都不會讓流程變紅燈,全程綠燈,直到出事那天。
「簽得出來 = 刷過臉」這條推論,前提整個壓在 App 建鑰時的兩個旗標上;伺服器驗不出來,所以你只有兩個選擇:驗收那幾行程式碼,或做 attestation。另外記得:金鑰綁的是「當下那組生物特徵」,特徵一變就永久作廢——而且是晶片拒簽,不是 App 判斷。
KeyGenParameterSpec(作廢新生物特徵的動機原文、「irreversibly invalidated」、初始化加密操作會丟 KeyPermanentlyInvalidatedException)BIOMETRIC_STRONG 才能傳 CryptoObject;Class 2 不行,要改用時間窗;BiometricPrompt 與 CryptoObject 的用法)setUserAuthenticationParameters)biometryCurrentSet(「currently enrolled fingers」、「invalidated if fingers are added or removed… or if the user re-enrolls for Face ID」).privateKeyUsage、kSecAttrTokenIDSecureEnclave、不可匯入/匯出)kSecUseAuthenticationContext(「previously authenticated… will succeed without asking user for authentication」);LAAccessControlOperation.useKeySign;LAContext.evaluatedPolicyDomainState(比對前後值可偵測變動但不知變了什麼;iOS 18 起 deprecated,改用 domainState)authenticatorData 的 UP/UV 旗標、attestation 的標準格式)