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

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 是裝置開給伺服器的證明——「這把鑰匙是在哪種硬體、什麼保護條件下產生的」。**

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

- **Android Key Attestation**
- **Apple App Attest**
- 或走標準 WebAuthn 的 **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 引導重新註冊新金鑰

  ①
  
  條件成立

  集合變過

下載 SVG

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

- **Android**：javadoc 寫得很直接——需要每次驗證的金鑰，「once a new biometric is enrolled or once no more biometrics are enrolled」就會被**不可逆地作廢**，而且初始化加密操作時會丟出 `KeyPermanentlyInvalidatedException`。
- 注意 **initialize** 這個字：例外是在 `Signature.initSign(key)` 那一步就丟出來的，而生物辨識視窗是之後才叫的——**所以畫面根本不會跳**。（**「prompt 不會跳」這句是從呼叫順序推出來的，官方文件沒有逐字這樣寫。**）
- **iOS**：Apple 對 `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 的決定**——所以產品上一定要準備「重新註冊」這條退路，別讓使用者卡在「按了沒反應」。

## 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 判斷。

## 🔗這系列的另外兩篇

[系列 1／3**6 位數 PIN 與九宮格圖形，到底該怎麼存** — 雜湊／salt／慢函數三個詞、為什麼光雜湊救不了 6 位數，以及三道防線。](https://docs.smartech.tw/n/6-位數-pin-與九宮格圖形-到底該怎麼存)
  [系列 2／3**生物辨識：為什麼伺服器只存一把公鑰** — 印章與印鑑卡的比喻、challenge 在防什麼、FIDO2 是機制而 WebAuthn 只是其中一種介面。](https://docs.smartech.tw/n/生物辨識-為什麼伺服器只存一把公鑰)

## 📚參考來源

- [Android（AOSP 原始碼中的官方 javadoc）— `KeyGenParameterSpec`](https://android.googlesource.com/platform/prebuilts/fullsdk/sources/android-30/+/refs/heads/androidx-biometric-release/android/security/keystore/KeyGenParameterSpec.java)（作廢新生物特徵的動機原文、「irreversibly invalidated」、初始化加密操作會丟 `KeyPermanentlyInvalidatedException`）
- [Android Developers — Biometric authentication](https://developer.android.com/identity/sign-in/biometric-auth)（Class 3 `BIOMETRIC_STRONG` 才能傳 `CryptoObject`；Class 2 不行，要改用時間窗；`BiometricPrompt` 與 `CryptoObject` 的用法）
- [Android Developers — Keystore system](https://developer.android.com/privacy-and-security/keystore)（金鑰材料不進 App 行程、StrongBox、`setUserAuthenticationParameters`）
- [Apple Developer — `biometryCurrentSet`](https://developer.apple.com/documentation/security/secaccesscontrolcreateflags/biometrycurrentset)（「currently enrolled fingers」、「invalidated if fingers are added or removed… or if the user re-enrolls for Face ID」）
- [Apple Developer — Protecting keys with the Secure Enclave](https://developer.apple.com/documentation/security/protecting-keys-with-the-secure-enclave)（`.privateKeyUsage`、`kSecAttrTokenIDSecureEnclave`、不可匯入／匯出）
- [Apple Developer — `kSecUseAuthenticationContext`](https://developer.apple.com/documentation/security/ksecuseauthenticationcontext)（「previously authenticated… will succeed without asking user for authentication」）；[`LAAccessControlOperation.useKeySign`](https://developer.apple.com/documentation/localauthentication/laaccesscontroloperation)；[`LAContext.evaluatedPolicyDomainState`](https://developer.apple.com/documentation/localauthentication/lacontext/evaluatedpolicydomainstate)（比對前後值可偵測變動但不知變了什麼；**iOS 18 起 deprecated**，改用 `domainState`）
- [W3C Web Authentication Level 3](https://www.w3.org/TR/webauthn-3/)（§6.1 `authenticatorData` 的 UP／UV 旗標、attestation 的標準格式）
- [NIST SP 800-63B rev.4](https://pages.nist.gov/800-63-4/sp800-63b.html)（§3.2.3 生物辨識只能搭配實體驗證器作多因子、比對應在本機執行）
