使用 DeviceCheck(DCDevice 每裝置位元)和 App Attest(DCAppAttestService 金鑰生成、認證和斷言流程)驗證裝置合法性與應用程式完整性。適用於實作詐欺防範、偵測遭入侵裝置、透過 Apple 伺服器驗證應用程式真實性、使用認證請求保護敏感 API 端點,或為後端架構加入裝置驗證。
裝置完整性
確保發送到您伺服器的請求來自真實的 Apple 裝置,且執行的是您應用程式的合法實例。DeviceCheck 提供每裝置位元,用於簡單標記(例如「已領取促銷優惠」)。App Attest 使用安全隔離區金鑰和 Apple 認證,以密碼學方式證明敏感請求的應用程式合法性。
目錄
- DCDevice(DeviceCheck 令牌)
- DCAppAttestService(App Attest)
- App Attest 金鑰生成
- App Attest 認證流程
- App Attest 斷言流程
- 伺服器驗證指引
- 錯誤處理
- 常見模式
- 常見錯誤
- 審查清單
- 參考資料
DCDevice(DeviceCheck 令牌)
DCDevice 產生一個唯一且短暫的令牌,用於識別裝置。請將每個令牌視為一次性使用:為每次伺服器操作產生新令牌,而非快取或重複使用。令牌會發送到您的伺服器,再由伺服器與 Apple 伺服器通訊,以讀取或設定兩個每裝置位元。適用於 iOS 11 以上版本。
令牌產生
import DeviceCheck
func generateDeviceToken() async throws -> Data {
guard DCDevice.current.isSupported else {
throw DeviceIntegrityError.deviceCheckUnsupported
}
return try await DCDevice.current.generateToken()
}
將令牌發送到您的伺服器
func sendTokenToServer(_ token: Data) async throws {
let tokenString = token.base64EncodedString()
var request = URLRequest(url: serverURL.appending(path: "verify-device"))
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode(["device_token": tokenString])
let (_, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw DeviceIntegrityError.serverVerificationFailed
}
}
伺服器端概覽
伺服器將每個新令牌與 Apple 的已驗證 DeviceCheck API 交換。請載入 DeviceCheck 伺服器端點 以取得端點和環境詳細資訊。
兩個位元的用途
Apple 為每個開發者團隊的每台裝置儲存兩個布林值。您可以自行定義其意義。常見用途:
- 位元 0: 裝置已領取促銷優惠。
- 位元 1: 裝置已被標記為詐欺。
位元在應用程式重新安裝後仍會保留。您可以透過伺服器 API 控制何時重設它們。
DCAppAttestService(App Attest)
DCAppAttestService 驗證特定裝置上特定應用程式實例的合法性。它使用安全隔離區中硬體支援的金鑰來建立密碼學認證和斷言。適用於 iOS 14 以上版本。
流程分為三個階段:
- 金鑰生成 — 在安全隔離區中建立金鑰對。
- 認證 — Apple 證明金鑰屬於執行您應用程式的真實 Apple 裝置。
- 斷言 — 使用已認證的金鑰簽署伺服器請求,以證明持續的合法性。
檢查支援性
import DeviceCheck
let attestService = DCAppAttestService.shared
guard attestService.isSupported else {
// 退回使用 DCDevice 令牌或其他風險評估。
// App Attest 在模擬器或部分裝置型號上不可用。
return
}
對於應用程式擴充,App Attest 僅支援 Action、可擴充 SSO 和 watchOS 擴充。即使 isSupported 回傳 true,也請將其他擴充類型視為不支援。
App Attest 金鑰生成
為每台裝置上的每個使用者帳戶產生一個密碼學金鑰對。私鑰保留在安全隔離區中。回傳的 keyId 是您的應用程式日後存取該金鑰的唯一識別碼,因此請記錄並重複使用帳戶/裝置範圍的 keyId;請勿在不同使用者之間共用金鑰。避免不必要的重新生成,因為每個新金鑰都會影響 App Attest 金鑰計數風險指標。只有在您的伺服器驗證認證後,才將 keyId 視為可用。如果伺服器驗證失敗,請捨棄該 keyId 並在重試前生成新金鑰。
import DeviceCheck
actor AppAttestManager {
private let service = DCAppAttestService.shared
private var keyId: String?
/// 生成並記錄 App Attest 的金鑰對。
func generateKeyIfNeeded() async throws -> String {
if let existingKeyId = loadKeyIdFromKeychain() {
self.keyId = existingKeyId
return existingKeyId
}
let newKeyId = try await service.generateKey()
saveKeyIdToKeychain(newKeyId)
self.keyId = newKeyId
return newKeyId
}
// MARK: - Keychain 輔助方法(簡化)
private func saveKeyIdToKeychain(_ keyId: String) {
let data = Data(keyId.utf8)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: "app-attest-key-id-\(currentAccountID)",
kSecAttrService as String: Bundle.main.bundleIdentifier ?? "",
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
]
SecItemDelete(query as CFDictionary) // 若存在則移除舊的
SecItemAdd(query as CFDictionary, nil)
}
private func loadKeyIdFromKeychain() -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: "app-attest-key-id-\(currentAccountID)",
kSecAttrService as String: Bundle.main.bundleIdentifier ?? "",
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data else { return nil }
return String(data: data, encoding: .utf8)
}
}
App Attest 認證流程
認證用於證明金鑰是在真實 Apple 裝置上執行合法應用程式實例時生成的。您對每個金鑰執行一次認證,然後在伺服器上儲存驗證過的公開金鑰和收據。在伺服器接受認證後,應用程式會儲存 keyId 以供未來斷言使用。
客戶端認證
import DeviceCheck
import CryptoKit
extension AppAttestManager {
/// 向 Apple 認證金鑰。將認證物件發送到您的伺服器。
func attestKey() async throws -> Data {
guard let keyId else {
throw DeviceIntegrityError.keyNotGenerated
}
// 1. 從您的伺服器請求一次性挑戰
let challenge = try await fetchServerChallenge()
// 2. 雜湊挑戰(Apple 要求 SHA-256 雜湊)
let challengeHash = Data(SHA256.hash(data: challenge))
// 3. 要求 Apple 認證金鑰
let attestation = try await service.attestKey(keyId, clientDataHash: challengeHash)
// 4. 將認證物件發送到您的伺服器進行驗證
try await sendAttestationToServer(
keyId: keyId,
attestation: attestation,
challenge: challenge
)
return attestation
}
private func fetchServerChallenge() async throws -> Data {
let url = serverURL.appending(path: "attest/challenge")
let (data, _) = try await URLSession.shared.data(from: url)
return data
}
private func sendAttestationToServer(
keyId: String,
attestation: Data,
challenge: Data
) async throws {
var request = URLRequest(url: serverURL.appending(path: "attest/verify"))
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let payload: [String: String] = [
"key_id": keyId,
"attestation": attestation.base64EncodedString(),
"challenge": challenge.base64EncodedString()
]
request.httpBody = try JSONEncoder().encode(payload)
let (_, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw DeviceIntegrityError.attestationVerificationFailed
}
}
}
伺服器端認證驗證
伺服器必須在客戶端將 keyId 視為可用之前驗證認證,然後儲存驗證過的公開金鑰和收據。請載入 伺服器端認證驗證 以取得憑證、App ID、環境、計數器、憑證和隨機數檢查的詳細資訊。
App Attest 斷言流程
認證完成後,使用斷言來簽署敏感請求。每個斷言證明請求來自已認證的應用程式實例,並包含伺服器發出的一次性挑戰以防止重放。
客戶端斷言
import DeviceCheck
import CryptoKit
extension AppAttestManager {
/// 為編碼後的客戶端資料生成斷言。
/// 客戶端資料應包含一次性伺服器挑戰和請求上下文。
func generateAssertion(for clientData: Data) async throws -> Data {
guard let keyId else {
throw DeviceIntegrityError.keyNotGenerated
}
let clientDataHash = Data(SHA256.hash(data: clientData))
return try await service.generateAssertion(keyId, clientDataHash: clientDataHash)
}
}
在網路請求中使用斷言
struct AppAttestClientData: Encodable {
let challenge: String
let method: String
let path: String
let bodySHA256: String
}
extension AppAttestManager {
/// 執行已認證的 API 請求。
func makeAttestedRequest(
to url: URL,
method: String = "POST",
body: Data
) async throws -> (Data, URLResponse) {
let challenge = try await fetchAssertionChallenge()
let bodyHash = Data(SHA256.hash(data: body)).base64EncodedString()
let clientData = try JSONEncoder().encode(
AppAttestClientData(
challenge: challenge,
method: method,
path: url.path,
bodySHA256: bodyHash
)
)
let assertion = try await generateAssertion(for: clientData)
var request = URLRequest(url: url)
request.httpMethod = method
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue(assertion.base64EncodedString(), forHTTPHeaderField: "X-App-Attest-Assertion")
request.setValue(clientData.base64EncodedString(), forHTTPHeaderField: "X-App-Attest-Client-Data")
request.httpBody = body
return try await URLSession.shared.data(for: request)
}
private func fetchAssertionChallenge() async throws -> String {
let url = serverURL.appending(path: "assert/challenge")
let (data, _) = try await URLSession.shared.data(from: url)
return String(decoding: data, as: UTF8.self)
}
}
伺服器端斷言驗證
伺服器必須在授權請求之前驗證每個斷言的簽章、RP ID、計數器、一次性挑戰和請求綁定。請載入 伺服器端斷言驗證 以取得完整演算法。
伺服器驗證指引
請參閱 references/device-integrity-patterns.md 以取得完整的伺服器架構指引,包括認證與斷言比較、建議的端點設計和風險評估。
安全邊界
App Attest 為選定的請求證明應用程式實例的完整性。它不取代使用者驗證、OAuth/JWT/工作階段處理、API 令牌設計、權限或訂閱授權、TLS、憑證固定或一般網路安全。請將這些視為驗證、網路或更廣泛安全指引的交接事項,並在 App Attest 通過後仍強制執行正常的驗證和授權。
錯誤處理
處理 DeviceCheck 操作中的 DCError 代碼。關鍵情況:
.serverUnavailable— 使用指數退避重試.invalidKey— 金鑰已認證、斷言使用了未認證的金鑰,或服務拒絕了該金鑰.featureUnsupported— 退回使用DCDevice令牌.invalidInput— 格式錯誤的clientDataHash或keyId
對於 attestKey,稍後使用相同的 keyId 和相同的 clientDataHash 重試 .serverUnavailable。對於其他認證錯誤,請捨棄金鑰識別碼並在重試前建立新金鑰。請參閱 references/device-integrity-patterns.md 以取得完整的錯誤處理程式碼、重試策略和遭拒金鑰復原。
常見模式
環境權限
在您的 entitlements 檔案中設定 App Attest 環境。測試時使用 development,App Store 建置使用 production。請載入 環境權限 以取得 XML、預設沙盒行為、發佈行為和擴充限制。
請參閱 references/device-integrity-patterns.md 以取得完整的整合管理器模式、逐步推出指引和錯誤類型定義。
常見錯誤
- 每次啟動都生成新金鑰。 為每台裝置上的每個使用者帳戶生成一次,持久化儲存
keyId,並保持金鑰計數較低。 - 重複使用
DCDevice令牌。 將生成的令牌視為一次性使用。為每次伺服器操作生成新令牌。 - 跳過對不支援裝置或擴充的退回處理。 並非所有裝置和擴充類型都支援 App Attest。使用
DCDevice令牌或其他風險評估作為退回方案。 - 在客戶端信任認證。 所有驗證必須在您的伺服器上進行。
- 僅簽署原始請求主體。 斷言客戶端資料必須包含一次性伺服器挑戰和足夠的請求上下文,以便伺服器將斷言綁定到請求。
- 驗證錯誤的認證隨機數。 將憑證擴充與
SHA256(authData || SHA256(challenge))進行比較,而非僅SHA256(challenge)。 - 未實作重放保護。 伺服器必須驗證一次性挑戰並追蹤斷言計數器。
- 混合開發和生產環境。 沙盒金鑰和收據在生產環境中無法使用,生產金鑰和收據在沙盒中也無法使用。
- 未處理
DCError.invalidKey。 檢查重複認證、未認證的斷言金鑰或服務拒絕;僅在確定狀態異常後才重新生成。
審查清單
- [ ]
DCDevice令牌為每次伺服器操作生成,且從未快取重複使用 - [ ] 使用前檢查
DCAppAttestService.isSupported;不支援的裝置和擴充類型有退回方案 - [ ] 金鑰為每台裝置上的每個使用者帳戶生成一次,且
keyId僅為該應用程式帳戶/裝置持久化儲存 - [ ] 每個金鑰執行一次認證;伺服器儲存驗證過的公開金鑰和收據
- [ ] 伺服器驗證認證憑證鏈、App ID 雜湊、環境
aaguid、憑證 ID 和隨機數SHA256(authData || SHA256(challenge)) - [ ] 斷言包含一次性挑戰和請求上下文;伺服器驗證簽章、RP ID、計數器、挑戰和請求綁定
- [ ] 受保護的端點在 App Attest 通過後仍強制執行正常的使用者驗證和權限授權
- [ ] 處理
DCError情況:.serverUnavailable使用相同金鑰/雜湊重試認證;錯誤金鑰被捨棄並重新生成 - [ ] App Attest 環境權限與沙盒/生產伺服器路由一致
- [ ] 考慮逐步推出;功能開關已就緒以啟用/停用




