🚀 快速安装

复制以下命令并运行,立即安装此 Skill:

npx skills add https://skills.sh/dpearson2699/swift-ios-skills/device-integrity

💡 提示:需要 Node.js 和 NPM

设备完整性

验证发送到您服务器的请求是否来自运行您未经修改应用的真正 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 端点:

端点 用途
https://api.devicecheck.apple.com/v1/query_two_bits 读取设备的两个位
https://api.devicecheck.apple.com/v1/update_two_bits 设置设备的两个位
https://api.devicecheck.apple.com/v1/validate_device_token 验证设备令牌而不读取位

服务器使用来自 Apple 开发者门户的 DeviceCheck 私钥进行身份验证,为每个请求创建一个签名的 JWT。

两个位的用途

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 密钥生成

生成存储在安全隔区中的加密密钥对。返回的 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",
            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",
            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)
    }
}

重要提示: 密钥生成一次,并持久化 keyId。生成新密钥会使任何先前的认证失效。

App Attest 认证流程

认证证明密钥是在运行您未经修改应用的真正 Apple 设备上生成的。每个密钥执行一次认证,然后在您的服务器上存储认证对象。

客户端认证

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
        }
    }
}

服务器端认证验证

您的服务器必须:

  1. 验证认证对象是一个有效的 CBOR 编码结构。
  2. 提取证书链并针对 Apple 的 App Attest 根 CA 进行验证。
  3. 验证认证中的 nonce 是否匹配 SHA256(challenge)
  4. 提取并存储公钥和收据,以备将来断言验证使用。

有关完整的服务器验证算法,请参阅 验证连接到服务器的应用

App Attest 断言流程

认证之后,使用断言来签名单个请求。每个断言证明请求来自经过认证的应用实例。

客户端断言

import DeviceCheck
import CryptoKit

extension AppAttestManager {
    /// 生成断言以伴随服务器请求。
    /// - Parameter requestData: 要签名的请求负载(例如,JSON 体)。
    /// - Returns: 要包含在请求中的断言数据。
    func generateAssertion(for requestData: Data) async throws -> Data {
        guard let keyId else {
            throw DeviceIntegrityError.keyNotGenerated
        }

        // 哈希请求数据 -- 服务器将验证此哈希是否匹配
        let clientDataHash = Data(SHA256.hash(data: requestData))

        return try await service.generateAssertion(keyId, clientDataHash: clientDataHash)
    }
}

在网络请求中使用断言

extension AppAttestManager {
    /// 执行经过认证的 API 请求。
    func makeAttestedRequest(
        to url: URL,
        method: String = "POST",
        body: Data
    ) async throws -> (Data, URLResponse) {
        let assertion = try await generateAssertion(for: body)

        var request = URLRequest(url: url)
        request.httpMethod = method
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.setValue(assertion.base64EncodedString(), forHTTPHeaderField: "X-App-Assertion")
        request.httpBody = body

        return try await URLSession.shared.data(for: request)
    }
}

服务器端断言验证

您的服务器必须:

  1. 解码断言(CBOR)。
  2. 验证认证者数据,包括计数器(必须大于存储的计数器)。
  3. 使用认证时存储的公钥验证签名。
  4. 确认 clientDataHash 与接收到的请求体的 SHA-256 哈希匹配。
  5. 更新存储的计数器以防止重放攻击。

服务器端验证指南

认证 vs. 断言

阶段 时机 证明内容 频率
认证 密钥生成后 密钥位于运行您未经修改应用的真正 Apple 设备上 每个密钥一次
断言 每个敏感请求 请求来自经过认证的应用实例 每个请求

推荐的服务器架构

  1. 挑战端点 — 生成随机 nonce,在服务器端存储并设置短 TTL(例如 5 分钟)。
  2. 认证验证端点 — 验证认证对象,存储由 keyId 索引的公钥和收据。
  3. 断言验证中间件 — 在敏感端点(购买、账户更改)上验证断言。

风险评估

将 App Attest 与 欺诈风险评估 结合,实现纵深防御。仅靠 App Attest 并不能保证用户没有滥用应用——它确认应用是真实的。

错误处理

DCError 代码

import DeviceCheck

func handleAttestError(_ error: Error) {
    if let dcError = error as? DCError {
        switch dcError.code {
        case .unknownSystemFailure:
            // 瞬态系统错误 -- 使用指数退避重试
            break
        case .featureUnsupported:
            // 设备或操作系统不支持此功能
            // 回退到替代验证方法
            break
        case .invalidKey:
            // 密钥已损坏或被失效
            // 生成新密钥并重新认证
            break
        case .invalidInput:
            // clientDataHash 或 keyId 格式错误
            break
        case .serverUnavailable:
            // Apple 的认证服务器不可达 -- 稍后重试
            break
        @unknown default:
            break
        }
    }
}

重试策略

extension AppAttestManager {
    func attestKeyWithRetry(maxAttempts: Int = 3) async throws -> Data {
        var lastError: Error?

        for attempt in 0..<maxAttempts {
            do {
                return try await attestKey()
            } catch let error as DCError where error.code == .serverUnavailable {
                lastError = error
                let delay = UInt64(pow(2.0, Double(attempt))) * 1_000_000_000
                try await Task.sleep(nanoseconds: delay)
            } catch {
                throw error // 不可重试的错误立即传播
            }
        }

        throw lastError ?? DeviceIntegrityError.attestationFailed
    }
}

处理失效密钥

如果 attestKey 返回 DCError.invalidKey,则安全隔区密钥已失效(例如,操作系统更新、安全隔区重置)。从钥匙串中删除存储的 keyId 并生成新密钥:

extension AppAttestManager {
    func handleInvalidKey() async throws -> String {
        deleteKeyIdFromKeychain()
        keyId = nil
        return try await generateKeyIfNeeded()
    }

    private func deleteKeyIdFromKeychain() {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: "app-attest-key-id",
            kSecAttrService as String: Bundle.main.bundleIdentifier ?? ""
        ]
        SecItemDelete(query as CFDictionary)
    }
}

常见模式

完整集成管理器

将上述模式组合到一个 actor 中,管理完整生命周期:

  1. 检查 isSupported,在不支持的设备上回退到 DCDevice 令牌。
  2. 在启动时调用 generateKeyIfNeeded() 以创建或加载持久化的密钥。
  3. 在密钥生成后调用一次 attestKeyWithRetry()
  4. 在每个敏感的服务器请求上使用 generateAssertion(for:)
  5. 通过重新生成和重新认证处理 DCError.invalidKey

渐进式推出

Apple 建议渐进式推出。将 App Attest 置于远程功能标志之后,并在不支持的设备上回退到 DCDevice 令牌。

环境权限

在您的权限文件中设置 App Attest 环境。在测试期间使用 development,在 App Store 构建中使用 production

<key>com.apple.developer.devicecheck.appattest-environment</key>
<string>production</string>

当缺少此权限时,系统在调试构建中使用 development,在 App Store 和 TestFlight 构建中使用 production

错误类型

enum DeviceIntegrityError: Error {
    case deviceCheckUnsupported
    case keyNotGenerated
    case attestationFailed
    case attestationVerificationFailed
    case assertionFailed
    case serverVerificationFailed
}

常见错误

  1. 每次启动都生成新密钥。 生成一次,将 keyId 持久化在钥匙串中。
  2. 跳过对不支持设备的回退。 并非所有设备都支持 App Attest。使用 DCDevice 令牌作为回退。
  3. 在客户端信任认证结果。 所有验证必须在您的服务器上进行。
  4. 未实施重放保护。 服务器必须跟踪并递增断言计数器。
  5. 缺少环境权限。 没有它,调试构建使用 development,App Store 使用 production。不匹配会导致认证失败。
  6. 未处理 DCError.invalidKey 密钥可能因操作系统更新而失效。检测并重新生成。

检查清单

  • 在使用前检查 DCAppAttestService.isSupported;不支持时回退到 DCDevice
  • 密钥生成一次,并将 keyId 持久化在钥匙串中
  • 每个密钥执行一次认证;认证对象发送到服务器
  • 服务器针对 Apple 的 App Attest 根 CA 验证认证
  • 为每个敏感请求生成断言;服务器验证签名和计数器
  • 处理 DCError 情况:.serverUnavailable 时重试,.invalidKey 时重新生成密钥
  • 正确设置调试与生产环境的 App Attest 环境权限
  • 考虑渐进式推出;为启用/禁用设置功能标志

📄 原始文档

完整文档(英文):

https://skills.sh/dpearson2699/swift-ios-skills/device-integrity

💡 提示:点击上方链接查看 skills.sh 原始英文文档,方便对照翻译。

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。