L0 的骨架是:平台在保險箱裡蓋章,伺服器端負責驗章。L0 也預告過:Google 有一種請求方式由平台自動防重送,你只要把這次請求的內容綁進去。這一站就把那句話對上具體名詞,看 Android 上的 Play Integrity API 怎麼跑。
這站學完,你要能用自己的話回答:
① standard 請求和 classic 請求各在什麼時候用?各自怎麼防重送?
② 判定結果分成哪幾類?每一類在問什麼?
③ 伺服器拿到判定結果後,除了「放行/拒絕」還能怎麼回應?
④ 跟 Apple App Attest 比,Google 最大的不同在哪?
| 名詞 | 白話 |
|---|---|
| Play Integrity API | Google 的設備證明服務。回答「這是從 Google Play 裝的正版 App、跑在真的認證 Android 裝置上嗎?」 |
| Google Play 元件 | 手機上 Google 提供的系統程式:Google Play Store 與 Google Play services 的合稱。它屬於平台,不是你的 App。 |
| integrity token | App 向 Google Play 元件要到的一段加密又簽過章的資料。App 看不懂內容,只負責轉交。 |
| verdict(判定結果) | token 解密後的內容:一份 JSON,寫著平台已經判斷好的結論,例如「App 被 Google Play 認得」。 |
| standard 請求 | 大多數情況用的請求方式:事先準備,之後每次要 token 都很快(平均幾百毫秒)。 |
| classic 請求 | 原本的請求方式:每次都重新評估,比較慢(平均幾秒),只留給少數最重要的操作。 |
| requestHash | standard 請求用的欄位:把這次請求內容做成的雜湊,交給 Play Integrity,之後原封不動出現在判定結果裡。 |
| Play Console | Google Play 給開發者的管理後台:設定 App、開啟選用的判定項目、申請提高配額都在這裡。 |
| service account | 給伺服器用的 Google 帳號,讓你的伺服器有權限呼叫 Google 的 API(例如請 Google 解密 token)。 |
角色對應:L0 的「平台」在這裡=Google Play 元件(在手機上)+安全硬體+Google 伺服器。
比喻延續「章」:Apple 是「開印鑑證明給你,伺服器自己存樣本、自己驗章」;Google 則像把蓋好章的密封信封交給你(信封=加密,章=簽章),你拿去 Google 的驗件窗口拆封,窗口直接告訴你結論。
MEETS_DEVICE_INTEGRITY 在沒有硬體時,會退回軟體證明。兩種請求方式,判定結果的格式一樣:
| standard 請求 | classic 請求 | |
|---|---|---|
| 適合 | 任何 App,隨時檢查任何操作 | 偶爾一次,檢查最有價值、最敏感的操作 |
| 速度 | 平均幾百毫秒(要先準備,約幾秒) | 平均幾秒 |
| 防竄改 | 用 requestHash 綁請求內容 | 用 nonce 綁請求內容 |
| 防重送 | Google Play 自動處理 | 你自己用 nonce 加伺服器邏輯處理 |
註:最低 Android 版本以 Overview 頁和 classic 頁的比較表為準,現行 library 兩種都是 Android 6.0 以上;standard、classic 頁首寫的 5.0/4.4 是 v1.3.0 以前的數字。
一句話:Play Integrity 是 Google 版的設備證明;大多數情況用 standard 請求,classic 只留給少數關鍵操作。
分兩段:事先準備,之後每次需要時要一個 token。
圖 A:standard 請求。Google Play 元件背後用到安全硬體的訊號,但那屬於平台,不在你的 App 裡(點圖可放大)
StandardIntegrityManager(App 端發 standard 請求的入口物件)呼叫 prepareIntegrityToken(),帶上你的 Google Cloud 專案編號(你在 Google Cloud 開的專案的編號,用來計算用量),拿到一個 token provider(準備好之後拿來要 token 的物件),放在記憶體裡。StandardIntegrityTokenRequest 的 setRequestHash 帶入,呼叫 token provider 的 request()。decodeIntegrityToken,拿回判定結果。// Request integrity token by providing a user action request hash.
String requestHash = "2cp24z...";
Task<StandardIntegrityToken> integrityTokenResponse =
integrityTokenProvider.request(
StandardIntegrityTokenRequest.builder()
.setRequestHash(requestHash)
.build());
(程式碼摘自 Android Developers 官方文件。)
requestHash 比對。不一樣就不能信。standard 請求自動防重送:Google Play 會阻止同一個 token 被重複使用很多次。同一個 token 被重複解密時,判定結果會被清空:
UNEVALUATED(未評估)。UNEVALUATED(多值的判定則變成空的)。Google 擋的是大量重複使用,timestampMillis 還是要你自己檢查。對回 L0 的三項檢查,standard 下是這樣分工:
requestHash。timestampMillis。一句話:standard 請求=事先準備 → 每次把請求內容綁進 requestHash 拿 token → 你的伺服器交給 Google 解密;「用過沒」由 Google 處理,「對不對」和「過期沒」你自己查。
IntegrityManager 的 requestIntegrityToken(),帶一個 nonce。之後一樣把 token 送到你的伺服器。名詞提醒:三個長得像的東西
・L0 的 challenge:伺服器發的一次性亂數,泛稱。
・classic 的nonce:你交給 Play Integrity 的欄位值,內容是唯一值,或「唯一值+訊息」的雜湊;會原封不動出現在判定結果裡。
・L1 Apple 的nonce:你的伺服器用 authData 和 clientDataHash 算出來比對用的值,不是你送出去的。
decodeIntegrityToken。一句話:classic=你自己產生唯一的 nonce、自己記錄防重送;token 可交給 Google 解,也可自己解。
不管 standard 或 classic,判定結果都是同一種格式的 JSON。官方要求先驗 requestDetails,再看其他類別。
圖 B:判定結果的五個類別(點圖可放大)
requestDetails:這是我這次的請求嗎?套件名稱要是你的 App,requestHash(classic 是 nonce)要對上,timestampMillis 要夠新。appIntegrity:是那個沒被改過的 App 嗎?看 App 和它的簽章憑證是否符合 Google Play 發行的版本。deviceIntegrity:裝置可信嗎?回傳一個或多個等級標籤;一個都不符合,就沒有標籤。accountDetails:使用者是從 Google Play 取得的嗎?看這個帳號有沒有這個 App 的 Play 授權。environmentDetails(選用):執行環境有沒有風險?要在 Play Console 開啟才會出現。requestDetails:requestPackageName(官方註明可能在途中被偽造)、standard 的 requestHash 或 classic 的 nonce、timestampMillis。appIntegrity:appRecognitionVerdict 是 PLAY_RECOGNIZED/UNRECOGNIZED_VERSION/UNEVALUATED;另有 packageName、certificateSha256Digest、versionCode(這三個在未評估時不會出現)。deviceIntegrity:deviceRecognitionVerdict 的標籤如下;另可開啟 deviceAttributes(sdkVersion)與 recentDeviceActivity(deviceActivityLevel:LEVEL_1~LEVEL_4、UNEVALUATED,看過去一小時要了幾次 token)。
| 標籤 | 預設或需開啟 | 意思 |
|---|---|---|
MEETS_DEVICE_INTEGRITY | 預設 | 真的、經過認證的 Android 裝置 |
MEETS_BASIC_INTEGRITY | 需在 Play Console 開啟 | 通過基本的系統完整性檢查,裝置不一定經過認證 |
MEETS_STRONG_INTEGRITY | 需在 Play Console 開啟 | 真的認證裝置,而且有近期安全更新 |
MEETS_VIRTUAL_INTEGRITY | 只在發佈到 Google Play Games for PC 時出現 | 帶 Google Play services 的 Android 模擬器,通過系統完整性檢查 |
accountDetails:appLicensingVerdict 是 LICENSED/UNLICENSED/UNEVALUATED。environmentDetails:appAccessRiskVerdict(appsDetected:KNOWN_/UNKNOWN_ 開頭的 INSTALLED、CAPTURING、CONTROLLING、OVERLAYS)、playProtectVerdict(NO_ISSUES、NO_DATA、POSSIBLE_RISK、MEDIUM_RISK、HIGH_RISK、UNEVALUATED)。一句話:先確認 requestDetails(是這次的請求),再看 App、裝置、帳號、環境各自可不可信。
requestDetails 對上;appRecognitionVerdict 是 PLAY_RECOGNIZED;appLicensingVerdict 是 LICENSED。MEETS_STRONG_INTEGRITY(Android 13 以上):最高級,要硬體訊號加上近期安全更新。MEETS_DEVICE_INTEGRITY(Android 13 以上)與 MEETS_STRONG_INTEGRITY(Android 13 以前):都靠硬體訊號。MEETS_DEVICE_INTEGRITY(Android 13 以前):有硬體就用硬體證明,沒有就退回軟體證明。MEETS_BASIC_INTEGRITY:最弱的一級,再下去就是沒有任何標籤。recentDeviceActivity 等級高的裝置,信任度就調低。Android 13 前後差在哪:Android 13 以上,MEETS_DEVICE_INTEGRITY 要有硬體證明開機程式鎖定、系統是原廠認證映像;MEETS_STRONG_INTEGRITY 還要裝置所有分割區在一年內有安全更新。Android 12 以下的 MEETS_STRONG_INTEGRITY 只要求硬體證明開機完整性、不要求近期更新,所以要搭配 deviceAttributes 的 sdkVersion 一起判斷。
官方提醒:越高等級的裝置越少,只認最高等級會擋掉很多人,所以建議分級處理。
(兩個平台共用的處理策略見 L3。)
補救流程:判定有問題時,App 可以用 showDialog 跳出 Google Play 的對話框請使用者自己修,例如 GET_LICENSED 引導到 Google Play 取得正版;要跳哪一種,由伺服器看完判定結果後決定。關掉對話框後再要一次 token 確認(standard 要重新準備)。
一句話:先過三項基本檢查,再依裝置等級分級回應;有問題時可以請使用者自己補救。
| Apple App Attest | Google Play Integrity | |
|---|---|---|
| 誰驗 | 你的伺服器自己驗憑證鏈、nonce、欄位 | Google 伺服器解密驗證,你拿回結果(classic 也可自己解) |
| 要一次性註冊嗎 | 要:attestation,伺服器存公鑰 | 不用伺服器端註冊(不必存公鑰)。standard 要由 App 事先準備,這步會連 Google 伺服器,但你的伺服器不參與 |
| 怎麼防重送 | 你發 challenge,加上 counter 遞增 | standard:Google 擋重複使用,你綁 requestHash、檢查時間戳 classic:你自己用 nonce 並記錄 |
| 伺服器拿到什麼 | 平台簽過的證明原料(註冊時是 Apple 開的憑證,之後是裝置金鑰蓋的章),結論自己算 | 平台已經判斷好的判定結果(JSON) |
兩邊差這麼多,伺服器要怎麼用同一套邏輯處理,是 L3 的主題。
ALLOWED_WINDOW_MILLIS),timestampMillis 太舊就不收。token provider 用太久也會過期,下次請求出現 INTEGRITY_TOKEN_PROVIDER_INVALID,這時要重新準備一個。sdkVersion 判斷(見上方分級)。PLAY_SERVICES_NOT_FOUND,可以用 GET_INTEGRITY 或 GET_STRONG_INTEGRITY 對話框引導使用者安裝或更新。一句話:配額要申請、結果別快取、時間戳要檢查、Android 13 前後標準不同、沒有 Google Play 元件就拿不到。
能不看筆記回答下面 5 題,這站就過了。
Level 3 會回到伺服器端,看怎麼用同一套邏輯同時接住 Apple 和 Google 兩邊,以及驗證失敗時怎麼降級。
showDialog 與對話框類型)PLAY_SERVICES_NOT_FOUND)