device-integrity

device-integrity

熱門

使用 DeviceCheck(DCDevice 每裝置位元)和 App Attest(DCAppAttestService 金鑰生成、認證和斷言流程)驗證裝置合法性與應用程式完整性。適用於實作詐欺防範、偵測遭入侵裝置、透過 Apple 伺服器驗證應用程式真實性、使用認證請求保護敏感 API 端點,或為後端架構加入裝置驗證。

936星標
47分支
更新於 2026/7/15
SKILL.md
唯讀
名稱
device-integrity
描述

使用 DeviceCheck(DCDevice 每裝置位元)和 App Attest(DCAppAttestService 金鑰生成、認證和斷言流程)驗證裝置合法性與應用程式完整性。適用於實作詐欺防範、偵測遭入侵裝置、透過 Apple 伺服器驗證應用程式真實性、使用認證請求保護敏感 API 端點,或為後端架構加入裝置驗證。

裝置完整性

確保發送到您伺服器的請求來自真實的 Apple 裝置,且執行的是您應用程式的合法實例。DeviceCheck 提供每裝置位元,用於簡單標記(例如「已領取促銷優惠」)。App Attest 使用安全隔離區金鑰和 Apple 認證,以密碼學方式證明敏感請求的應用程式合法性。

目錄

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 以上版本。

流程分為三個階段:

  1. 金鑰生成 — 在安全隔離區中建立金鑰對。
  2. 認證 — Apple 證明金鑰屬於執行您應用程式的真實 Apple 裝置。
  3. 斷言 — 使用已認證的金鑰簽署伺服器請求,以證明持續的合法性。

檢查支援性

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 — 格式錯誤的 clientDataHashkeyId

對於 attestKey,稍後使用相同的 keyId 和相同的 clientDataHash 重試 .serverUnavailable。對於其他認證錯誤,請捨棄金鑰識別碼並在重試前建立新金鑰。請參閱 references/device-integrity-patterns.md 以取得完整的錯誤處理程式碼、重試策略和遭拒金鑰復原。

常見模式

環境權限

在您的 entitlements 檔案中設定 App Attest 環境。測試時使用 development,App Store 建置使用 production。請載入 環境權限 以取得 XML、預設沙盒行為、發佈行為和擴充限制。

請參閱 references/device-integrity-patterns.md 以取得完整的整合管理器模式、逐步推出指引和錯誤類型定義。

常見錯誤

  1. 每次啟動都生成新金鑰。 為每台裝置上的每個使用者帳戶生成一次,持久化儲存 keyId,並保持金鑰計數較低。
  2. 重複使用 DCDevice 令牌。 將生成的令牌視為一次性使用。為每次伺服器操作生成新令牌。
  3. 跳過對不支援裝置或擴充的退回處理。 並非所有裝置和擴充類型都支援 App Attest。使用 DCDevice 令牌或其他風險評估作為退回方案。
  4. 在客戶端信任認證。 所有驗證必須在您的伺服器上進行。
  5. 僅簽署原始請求主體。 斷言客戶端資料必須包含一次性伺服器挑戰和足夠的請求上下文,以便伺服器將斷言綁定到請求。
  6. 驗證錯誤的認證隨機數。 將憑證擴充與 SHA256(authData || SHA256(challenge)) 進行比較,而非僅 SHA256(challenge)
  7. 未實作重放保護。 伺服器必須驗證一次性挑戰並追蹤斷言計數器。
  8. 混合開發和生產環境。 沙盒金鑰和收據在生產環境中無法使用,生產金鑰和收據在沙盒中也無法使用。
  9. 未處理 DCError.invalidKey 檢查重複認證、未認證的斷言金鑰或服務拒絕;僅在確定狀態異常後才重新生成。

審查清單

  • [ ] DCDevice 令牌為每次伺服器操作生成,且從未快取重複使用
  • [ ] 使用前檢查 DCAppAttestService.isSupported;不支援的裝置和擴充類型有退回方案
  • [ ] 金鑰為每台裝置上的每個使用者帳戶生成一次,且 keyId 僅為該應用程式帳戶/裝置持久化儲存
  • [ ] 每個金鑰執行一次認證;伺服器儲存驗證過的公開金鑰和收據
  • [ ] 伺服器驗證認證憑證鏈、App ID 雜湊、環境 aaguid、憑證 ID 和隨機數 SHA256(authData || SHA256(challenge))
  • [ ] 斷言包含一次性挑戰和請求上下文;伺服器驗證簽章、RP ID、計數器、挑戰和請求綁定
  • [ ] 受保護的端點在 App Attest 通過後仍強制執行正常的使用者驗證和權限授權
  • [ ] 處理 DCError 情況:.serverUnavailable 使用相同金鑰/雜湊重試認證;錯誤金鑰被捨棄並重新生成
  • [ ] App Attest 環境權限與沙盒/生產伺服器路由一致
  • [ ] 考慮逐步推出;功能開關已就緒以啟用/停用

參考資料