device-integrity

device-integrity

热门

使用 DeviceCheck(DCDevice 每设备位)和 App Attest(DCAppAttestService 密钥生成、证明和断言流程)验证设备合法性和应用完整性。适用于实现欺诈预防、检测受损设备、通过 Apple 服务器验证应用真实性、使用已证明请求保护敏感 API 端点,或为后端架构添加设备验证。

936Star
47Fork
更新于 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: - 钥匙串辅助方法(简化)

    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 获取完整的错误处理代码、重试策略和已拒绝密钥恢复。

常见模式

环境授权

在您的授权文件中设置 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 环境授权和沙盒/生产服务器路由一致
  • [ ] 考虑逐步推出;存在用于启用/禁用的功能标志

参考资料