# 設備證明 Level 3：伺服器端統一驗證兩個平台

Chance Lu

前三站講的是「每個平台怎麼運作」：L0 的骨架（**平台在保險箱裡蓋章，伺服器端驗章**）、L1 的 Apple App Attest、L2 的 Google Play Integrity。這一站是最後一站，講**你的伺服器要怎麼設計，才能同時接住兩邊**。

> 這站學完，你要能：
> ① 畫出一套伺服器驗證架構：發 challenge → 綁請求內容 → 分流驗證 → 正規化 → 依策略回應。
> ② 說出 Apple 和 Google 各要存什麼、各要呼叫什麼。
> ③ 說出「附了證明但驗不過」和「根本拿不到證明」各該怎麼處理，以及理由。
> ④ 說出設備證明在整體防線裡的位置。

**怎麼看這站的標記**：官方文件寫的內容，會在句尾附上出處。官方沒有要求、是設計上的選擇，會標 設計建議（非官方），你可以依自己的情況調整。

## 起點：L2 的對照表，伺服器要吸收的四個差異

|  | Apple App Attest | Google Play Integrity | 伺服器要處理什麼 |
| --- | --- | --- | --- |
| **誰驗** | 你的伺服器自己驗 | Google 伺服器解密驗證 | 兩套驗證邏輯 |
| **一次性註冊** | 要，伺服器存公鑰 | 不用伺服器端註冊 | 只有 Apple 要一張註冊表 |
| **防重送** | challenge＋counter | standard 由 Google 擋大量重複使用 | 兩種防重送手段 |
| **拿到什麼** | 證明原料，結論自己算 | 判斷好的判定結果 | 要翻成同一種「共同判定」 |

## 先認得這站的新名詞

| 名詞 | 白話 |
| --- | --- |
| **驗證器（verifier）** | 伺服器裡專門驗某個平台證明的那一塊程式。Apple 一個、Google 一個。 |
| **註冊表** | 伺服器存「Apple 金鑰」的地方：keyId 對應到公鑰和最新的 counter。就是 L1 說的**印鑑樣本存檔**。 |
| **正規化** | 把兩個平台長得不一樣的結果，**翻譯成同一種格式**，後面的程式只看這一種。 |
| **共同判定** | 正規化之後的結果，例如「App 可信嗎、裝置可信嗎、這次請求乾淨嗎」。 |
| **策略** | 「看到什麼判定，就怎麼回應」的規則表。 |

**角色不變**：App 遞件、平台蓋章、你的伺服器驗章（或請 Google 的驗件窗口代驗，就是 L2 說的拿去窗口拆封）並做決定。這站所有新東西都在「你的伺服器」裡。

## 統一流程的骨架

AppiOS／Android
  
  你的伺服器
  ① 發 challenge一次性、有時效
  註冊入口只有 Apple
  ③ 分流看是哪個平台
  Apple 驗證器驗章、challenge、counter
  註冊表keyId → 公鑰＋counter寫入前查公鑰沒綁別人
  Google 驗證器requestHash、時間戳
  ④ 正規化共同判定
  ⑤ 策略決定回應
  Google 伺服器解密 token

  ② 請求＋證明（已綁請求內容）

  回應：放行／限制／加驗／拒絕

圖 A：統一驗證架構。橘框的註冊入口只有 Apple 用；Google 那一路要呼叫 Google 伺服器。編號對應下面五步（點圖可放大）

1. **發 challenge**：
    - Apple：伺服器要提供隨機、一次性的 challenge，並記住它（Apple：Validating 頁）。WWDC26 也說，**註冊（attestation）要由伺服器控制何時發起**，理由是讓 App 維持在安全的每秒請求量以內。
    - Google standard 請求不需要伺服器發 challenge（Android：standard 頁）。
    - 兩個平台共用同一個發 challenge 入口，給 challenge 很短的有效時間，並綁定發給哪個使用者或 session 設計建議（非官方）。
2. **綁定請求內容**：本系列把 Apple 的 `clientDataHash`（assertion 時）和 Google 的 `requestHash` 對照來看，它們都是「把這次請求的內容做成雜湊，交給平台一起簽進去」，所以可以用同一套程式產生。這個對照是本系列的整理，官方沒有這樣並列。
3. **按平台分流驗證**：看請求來自哪個平台，交給對應的驗證器，檢查項目照 L1、L2 的官方清單。
4. **正規化成共同判定**：兩個驗證器都輸出同一種格式 設計建議（非官方）。
5. **依策略回應**：用分級回應，而不是只有放行和拒絕（Android：Overview 頁）。兩個平台共用同一張策略表 設計建議（非官方）。

**註冊入口（只有 Apple）**：App 送來 attestation object → Apple 驗證器驗過 → 寫入註冊表；寫入前確認這把公鑰沒有綁在別的使用者身上（Apple：Validating 頁）。

### Google 那一路要不要也自己發 challenge？

- **官方怎麼說**：standard 請求由 Google 阻止同一個 token 被**大量**重複使用（原文 reused many times），不是保證只能用一次（Android：standard 頁）。
- **設計建議**：Google 那一路也發 challenge，放進 requestHash 要雜湊的內容裡，就多一道「只能用一次」的檢查，兩個平台也能共用同一段程式 設計建議（非官方）。
- **代價**：多一趟往返（先要 challenge 再送請求），而且伺服器要存發出去的 challenge。
- **為什麼值得**：因為 Google 擋的是「大量」重複使用，所以少量重送仍要靠你自己的一次性檢查擋下；對敏感操作來說，多一趟往返換這道檢查通常划算。

> 一句話：一個入口發 challenge，兩個驗證器各驗各的，翻成同一種判定，再用同一張策略表回應；只有 Apple 多一條註冊入口。

## 平台差異怎麼吸收：各存什麼、各呼叫什麼

|  | Apple 驗證器 | Google 驗證器 |
| --- | --- | --- |
| **要存什麼** | 註冊表：公鑰（綁定使用者和裝置）、最新 counter、receipt；開發和正式環境分開存；一個使用者可能有多組（Apple：Validating 頁）<br>發出去、還沒用掉的 challenge（Apple：Validating 頁） | service account 的憑證，用來呼叫 Google API（Android：standard 頁）<br>classic：發過或看過的 nonce 紀錄（Android：classic 頁）<br>若採用「Google 那一路也發 challenge」：發出去、還沒用掉的 challenge 設計建議（非官方） |
| **要呼叫什麼** | 驗證時不用呼叫 Apple；只有選用的風險指標，才用 receipt 去問 Apple 伺服器（Apple：Assessing fraud risk 頁） | 每次都呼叫 `decodeIntegrityToken` 請 Google 解密（Android：standard 頁） |
| **註冊時多做的事** | 確認這把公鑰沒有綁在別的使用者身上（Apple：Validating 頁） | 無 |

- **Google 不用存每台裝置的資料**：官方的 standard 流程裡沒有「伺服器存裝置資料」這一步，所以你的伺服器不必為每台 Android 裝置建檔。
- **另外有 device recall**：Google 提供替每台裝置存少量自訂資料的功能，資料存在 Google 伺服器，App 重裝後仍在（Android：Overview 頁）。

位元組層級的驗證步驟（OID、X9.62 等），照 L1 收合區塊的逐步對照表做，這裡不重講。

### 兩道一次性檢查都要原子化

**原子化**＝「讀出舊值、比較、寫入新值」一次做完，中間不讓別的請求插隊。Apple 那一路有兩道一次性檢查：**challenge 的消耗**和 **counter 的更新**。

1. 攻擊者把同一個 assertion **同時**送兩次。
2. 如果兩個請求都先讀到「challenge 還沒用過」、「counter 是 7」，就都會通過，重送成功。
3. 所以兩道檢查都要一次完成 設計建議（非官方）：
    - **第一道防線是 challenge**：只有當這個 challenge 還沒被用過，才把它標成已用。
    - **counter**：只有當註冊表裡存的 counter **小於**這次收到的 counter，才寫入。

**原子化的做法與副作用（實作時看）**
- **做法**：資料庫的條件更新（寫入時帶上條件，並確認真的更新到一列），或對同一個 keyId 加鎖 設計建議（非官方）。
- **官方規則的副作用**：Apple 要求 counter 大於上一次，WWDC26 說要 strictly increasing。所以同一台裝置的正常請求如果亂序到達（counter 9 比 8 先到），8 就會被拒。
- **怎麼減少這種情況**：讓 App 對同一把金鑰的敏感請求一個一個送，或被拒時重新要 challenge 再試 設計建議（非官方）。
- **可疑訊號**：WWDC26 提醒，counter 停住或往回退，可能表示有被改過的 App 複本。

> 一句話：Apple 那一路存註冊表、自己驗；Google 那一路幾乎不存東西、每次請 Google 解密；challenge 和 counter 兩道一次性檢查都要原子化。

## 正規化：共同判定長什麼樣

下面是一種共同判定的格式，欄位名稱是本系列自訂的 設計建議（非官方）。

| 共同判定 | 在問什麼 | Apple 來源 | Google 來源 |
| --- | --- | --- | --- |
| **請求乾淨** | 沒被重送、沒被竄改嗎？ | 簽章驗得過、challenge 對得上、counter 有往上加 | `requestDetails` 對上、時間戳夠新 |
| **App 可信** | 是我那個沒被改過的 App 嗎？ | 註冊時 attestation 通過＋這次用註冊過的金鑰簽＋`RP ID` 對上 | `appRecognitionVerdict` |
| **取得管道／授權** | 使用者是從官方商店取得的嗎？ | 無對應 | `appLicensingVerdict` |
| **裝置等級** | 裝置可信到哪一級？ | 沒有分級（對應方式見下） | `deviceRecognitionVerdict` 的標籤 |
| **額外風險訊號** | 有沒有其他可疑跡象？ | 選用的風險指標、counter 停住或倒退 | `recentDeviceActivity`、`environmentDetails` |

- **被清空的判定**：Google 重複解密同一個 token 時，裝置判定會變空、App 判定變成 `UNEVALUATED`（Android：standard 頁），所以在這張表裡自然會落到最低一級，不必另外處理。
- **Apple 通過後算哪一級**：視同 `MEETS_DEVICE_INTEGRITY`，再依 receipt 的風險指標偏高、或 counter 異常往下調 設計建議（非官方）。
- **為什麼不能硬畫成完全對等**：Apple 驗過，代表 Apple 替「這把金鑰在 Apple 硬體上、跑在沒被改過的 App 裡」作證；但 Apple 自己也說，App Attest 無法百分之百找出作業系統被改過的裝置（Apple：DeviceCheck 頁）。

## 從判定到回應：決策圖

收到請求可能附有證明
  ① 有證明、拿得到結果？支援、服務正常、配額夠
  沒證明／拿不到結果當成「不可信」那一級
  ② 驗得過嗎？附了證明就一定要驗過
  驗不過 → 拒絕
  ③ 可信到哪一級？App 可信＋裝置等級
  高 → 放行
  中 → 限制功能
  低 → 加驗或請使用者補救
  不可信 → 依敏感度限制或加驗
  
  否
  
  是
  否
  是

圖 B：從共同判定到分級回應。整張決策圖是設計建議（非官方）；分級回應的想法來自 Google 官方建議（點圖可放大）

- **附了證明但驗不過**：一律當成「請求不乾淨」，直接拒絕，不走降級 設計建議（非官方）。
- **沒附證明，或拿不到結果**（不支援、故障、配額用完、重試後仍失敗）：當成「不可信」那一級，交給策略表決定 設計建議（非官方）。這個模型來自 Google：它的錯誤處理指引說，重試三次仍失敗，就當作沒通過所有檢查（Android：Error codes 頁）；本系列把這個模型套用到所有「拿不到結果」的情況。
- **加驗**＝不直接放行，多要求一步驗證，例如再輸入密碼、做「我不是機器人」驗證，或**請使用者自己補救**（L2 的補救對話框）。
- **分級回應**：Google 官方舉的例子是 Allow、Allow with limits、Allow with limits after CAPTCHA completion、Deny（Android：Overview 頁）。「高／中／低／不可信」怎麼切，要看你的業務 設計建議（非官方）。
- **高等級的裝置比較少**：只認最高等級會擋掉很多人（Android：Overview 頁）。

> 一句話：附了證明但驗不過就拒絕；拿不到證明就當「不可信」那一級；驗過了再看可信到哪一級。

## 拿不到證明時：各種情況怎麼處理

| 情況 | 官方怎麼說 | 設計上怎麼接 |
| --- | --- | --- |
| **裝置不支援** | Apple：`isSupported` 是 false 時優雅略過，伺服器那邊 "can no longer require assertions"（Apple：Establishing 頁）<br>Android：兩種請求都需要 Google Play Store 與 Google Play services（Android：classic 頁） | 「不支援」是 App 自己回報的，伺服器無法驗證，所以攻擊者可以假裝不支援；因此高風險操作不能因為「不支援」就放行 設計建議（非官方）。另一面，L0 引過的 OWASP Platform Lock-in 提醒：太依賴平台服務，會把用其他 Android 版本的正常使用者擋在外面，所以低風險操作要留一條路。 |
| **平台服務故障、逾時** | Apple：`serverUnavailable` 是 attestation 時連不到 Apple（Apple：DCError 頁），而 assertion 在裝置本機產生（WWDC26），所以**只影響新註冊**，已註冊的裝置照常能驗<br>Google：每次請求都要靠 Google 伺服器解密；官方建議事先規劃大規模故障時後端怎麼運作（Android：Overview 頁） | 替伺服器呼叫 `decodeIntegrityToken` 設逾時上限，逾時就當成「拿不到結果」 設計建議（非官方） |
| **配額用完、被限流** | Google：預設每天 10,000 次，可申請提高，建議設用量警報（Android：Setup 頁）；官方也建議在盡量接近要保護的操作時才要求判定（Android：Overview 頁，原文講的是 app access risk 判定）<br>Apple：被限流時要能把請求量降下來（Apple：Preparing 頁）；assertion 通常只留給敏感時刻（Apple：Establishing 頁） | 照官方方式處理：退避、提前申請提高配額、只在適當時機才要求證明 |
| **重試後仍失敗** | Google：**背景操作**（App 端的指引）用指數退避，重試三次仍失敗就當作沒通過所有檢查（Android：Error codes 頁）；使用者主動操作時，官方建議給看得懂的錯誤訊息，請使用者重試、開網路或更新 Play Store（Android：Overview 頁） | 當成「拿不到結果」，交給策略表 |
| **重裝 App、換新機** | Apple：金鑰在重裝、換機、從備份還原後失效，要重新註冊；不要一看到新金鑰就拒絕（Apple：Establishing 頁、WWDC26）<br>Google：官方 standard 流程裡伺服器沒有存裝置資料，所以 App 重新準備即可 | 允許重新註冊。Apple 官方建議只在這些情況才產生新金鑰，因為 "Keeping the key count low on a device helps when trying to detect certain kinds of fraud"（Apple：Establishing 頁）。官方是以裝置為單位；延伸成以帳號為單位，記錄同一帳號短時間內換了幾把金鑰、異常時加驗 設計建議（非官方） |

### 拿不到證明時：fail-open 還是 fail-closed

- **fail-open**＝拿不到證明時，**先放行（但限制）**；**fail-closed**＝拿不到證明時，**先擋下（加驗或拒絕）**。
- **低風險操作**（例如領每日簽到獎勵）：fail-open，放行但限制，靠其他訊號補 設計建議（非官方）。
- **高風險操作**（例如轉出點數）：fail-closed，改成加驗，而不是直接拒絕。Google 官方建議：遇到高價值或敏感功能，"instead of denying access outright. When possible, challenge risky traffic before allowing high-value actions to proceed"（Android：Overview 頁「Challenge risky traffic when accessing high value or sensitive features」）。
- **理由**：fail-closed 會讓平台一出問題就擋到所有正常使用者；fail-open 會讓攻擊者故意製造「拿不到證明」來繞過：Google 也說，拿不到結果的原因可能包括 "an attempt by an attacker"（Android：Error codes 頁）。依敏感度分開處理，是在這兩個風險之間取捨。

> 一句話：拿不到證明時，當成「不可信」那一級；低風險放行但限制，高風險加驗或擋下。

## 上線策略

**先只收集、不擋人**：Google 官方建議先接上 API、不執行，了解現有使用者會拿到什麼判定，再估算影響（Android：Overview 頁）。Apple 那一路也照樣做，觀察一段時間後再從最敏感的操作開始逐步收緊 設計建議（非官方）。

**分批上線與配額（實作時看）**
- **Apple**：官方的經驗法則是，每個 App 每天均勻增加不超過 1,000 萬名使用者；另外建議 `attestKey` 所有安裝加起來每秒少於 100 次（Apple：Preparing 頁）。1,000 萬／天換算約每秒 116 次，所以真正卡住的是**每秒 100 次**那一條。
- **Google**：預設每天 10,000 次；申請提高最多要一週處理（Android：Setup 頁），所以要在上線前先申請好。流量大幅變化要逐步上線（Android：Setup 頁）。
- 兩邊的限制要一起排進同一個上線計畫 設計建議（非官方）。

**建議監控的指標（實作時看）**
- Google 配額用量，並設用量警報（Android：Setup 頁）。
- Apple `attestKey` 的限流錯誤，要能即時把請求量降下來（Apple：Preparing 頁）；WWDC26 也說 "Implement an exponential back-off scheme to avoid hitting global rate limits."
- counter 停住或倒退的次數（WWDC26）。
- 各平台「拿不到證明」的比例，依錯誤種類分開看 設計建議（非官方）。
- 共同判定各等級的分布，以及各種回應的比例 設計建議（非官方）。
- 驗證延遲，特別是呼叫 Google 解密的時間 設計建議（非官方）。
- 同一帳號新金鑰的數量 設計建議（非官方）。

> 一句話：先觀察再收緊；Apple 真正的上限是每秒 100 次，Google 的配額要在上線前申請好。

## 設備證明在整體防線裡的位置

- **它只是一層訊號**：Google 說 Play Integrity 最好搭配其他訊號，不該是唯一的防濫用機制（Android：Overview 頁）；Apple 說沒有任何單一政策能消除所有詐騙，App Attest 提供的是可以整合進整體風險評估的資訊（Apple：DeviceCheck 頁）。
- **OWASP 的定位**：MASVS 把「驗證平台的完整性」（MASVS-RESILIENCE-1）和「防竄改機制」（MASVS-RESILIENCE-2）歸在韌性控制裡。韌性控制是**額外的保護**，原文說 "The absence of these measures does not in itself constitute a vulnerability"；App 仍然要依自己的威脅模型（according to their threat model），滿足 MASVS 其他所有控制項（OWASP MASVS-RESILIENCE）。
- **所以它不能取代**：登入驗證、權限檢查、伺服器端的業務規則（例如一個帳號一天能兌換幾次）。設備證明回答的是「是不是正版 App、真裝置」，不是「這個人有沒有權限做這件事」。

> 一句話：設備證明讓攻擊變貴，但登入、權限和業務規則還是得在伺服器端做好。

## 全系列回顧

- **L0**：伺服器不能相信 App 自己說的話；設備證明靠三塊積木撐著：簽章與憑證鏈、硬體保護的金鑰、challenge。
- **L1**：Apple 先註冊一次（印鑑登記），之後每次請求蓋章，**你的伺服器自己驗章**。
- **L2**：Google 把判定結果裝進密封信封，**拿去 Google 的驗件窗口代驗**。
- **L3**：你的伺服器用一套流程**統一接住兩邊**，再依策略分級回應。

## ✔ 過關標準

能不看筆記回答下面 5 題，這條 roadmap 就走完了。

1. 畫出統一驗證的五個步驟，以及 Apple 多出來的那條路。
發 challenge → 綁定請求內容（clientDataHash／requestHash）→ 按平台分流給 Apple 或 Google 驗證器 → 正規化成共同判定 → 依策略分級回應。Apple 另有註冊入口：驗 attestation、寫入註冊表，並確認公鑰沒綁在別的使用者上。
2. Apple 和 Google 驗證器各要存什麼、各要呼叫什麼？
Apple：存註冊表（公鑰、counter、receipt）和發出去的 challenge；驗證時不用呼叫 Apple。Google standard：存 service account 憑證，不必存每台裝置的資料（若自己發 challenge 就要存 challenge），每次呼叫 decodeIntegrityToken 請 Google 解密。
3. 為什麼 challenge 和 counter 都要原子化？
同一個 assertion 同時送兩次時，兩個請求可能都讀到「challenge 沒用過、counter 是舊值」，都通過，重送就成功了。第一道防線是 challenge 的消耗，第二道是 counter 的更新，兩道都要一次做完。（這是設計建議，官方只要求 challenge 一次性、counter 大於上次。）
4. 「附了證明但驗不過」和「拿不到證明」各怎麼處理？fail-open 和 fail-closed 是什麼？
驗不過：直接拒絕。拿不到證明：當成「不可信」那一級，交給策略表。fail-open＝拿不到證明時先放行但限制，適合低風險操作；fail-closed＝拿不到證明時先加驗或擋下，適合高風險操作。
5. 設備證明能取代登入驗證嗎？為什麼？
不能。它只回答「是不是正版 App、真裝置」。Google、Apple、OWASP 都說它只是整體防線的一部分；登入、權限和業務規則仍要在伺服器端做。

## 延伸閱讀

- 想把 Apple 那一路做完整：Apple 的「Validating apps that connect to your server」和「Attestation Object Validation Guide」。
- 想把 Google 那一路做完整：Android 的「Integrity verdicts」和 Overview 頁的安全建議段落。
- 想從威脅模型看整體防線：OWASP MASVS 的 MASVS-RESILIENCE，以及 MASTG 的對應測試。

## 參考來源

- [Apple Developer — DeviceCheck](https://developer.apple.com/documentation/devicecheck)（沒有單一政策能消除所有詐騙、無法百分之百找出被改過的作業系統）
- [Apple Developer — Validating apps that connect to your server](https://developer.apple.com/documentation/devicecheck/validating-apps-that-connect-to-your-server)（伺服器發 challenge 並記住、要存什麼、公鑰不可綁在別的使用者、counter 要大於上次）
- [Apple Developer — Establishing your app's integrity](https://developer.apple.com/documentation/devicecheck/establishing-your-app-s-integrity)（不支援時優雅略過、can no longer require assertions、assertion 留給敏感時刻、重裝後重來）
- [Apple Developer — Preparing to use the App Attest service](https://developer.apple.com/documentation/devicecheck/preparing-to-use-the-app-attest-service)（分批上線的經驗法則、每秒呼叫量建議、被限流時要能降量）
- [Apple Developer — Assessing fraud risk](https://developer.apple.com/documentation/devicecheck/assessing-fraud-risk)（receipt 與風險指標）
- [Apple Developer — DCError.serverUnavailable](https://developer.apple.com/documentation/devicecheck/dcerror-swift.struct/serverunavailable)（attestation 時連不到 Apple、用同樣輸入重試）
- [WWDC26 — Secure your apps with App Attest](https://developer.apple.com/videos/play/wwdc2026/201/)（伺服器控制何時發起 attestation、assertion 在本機產生、counter strictly increasing 與異常訊號、不要直接拒絕新金鑰）
- [Android Developers — Overview of the Play Integrity API](https://developer.android.com/google/play/integrity/overview)（搭配其他訊號、先收集再執行、分級與一系列回應、適當時機要求判定、看得懂的錯誤訊息、規劃故障、device recall）
- [Android Developers — Make a standard API request](https://developer.android.com/google/play/integrity/standard)（decodeIntegrityToken、service account、reused many times、清空規則）
- [Android Developers — Make a classic API request](https://developer.android.com/google/play/integrity/classic)（nonce 紀錄、需要 Google Play Store 與 Google Play services）
- [Android Developers — Set up Play Integrity API](https://developer.android.com/google/play/integrity/setup)（每日配額、申請提高最多一週、用量警報、逐步上線）
- [Android Developers — Play Integrity API error codes](https://developer.android.com/google/play/integrity/error-codes)（背景操作的重試策略、三次仍失敗視為未通過）
- [OWASP MASVS — MASVS-RESILIENCE](https://mas.owasp.org/MASVS/11-MASVS-RESILIENCE/)（RESILIENCE-1／-2、韌性控制是額外保護、依威脅模型滿足其他控制項）
- [OWASP MASVS — Platform Lock-in](https://mas.owasp.org/MASVS/11-MASVS-RESILIENCE/#platform-lock-in)（依賴平台服務可能排除正常使用者）
