mcpField
D
device-integrity

device-integrity

github.com/dpearson2699安全1.2k3.7k 次安装13 周前更新
代码仓库

关于 device-integrity

面向 Apple 应用开发者的 DeviceCheck 设备与应用完整性指南,介绍单次使用的设备令牌,以及 App Attest 的硬件密钥和服务器端验证要求。

主要功能

  1. 01生成单次使用的 DeviceCheck 令牌
  2. 02存储和管理设备级标志位
  3. 03创建由 Secure Enclave 支持的 App Attest 密钥
  4. 04证明密钥并为请求签名
  5. 05在服务器验证证明和断言

适用场景

  1. 01记录设备级促销领取或欺诈标志
  2. 02在敏感请求前验证应用实例
  3. 03将签名请求绑定到一次性挑战
  4. 04为不受支持的设备规划回退方案

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/dpearson2699/swift-ios-skills --skill device-integrity

安装后在 Claude Code 中输入/device-integrity确认已加载。

附带文件evals/evals.jsonreferences/device-integrity-patterns.md

详细介绍

设备完整性

验证发往你服务器的请求是否来自运行正版应用的真实 Apple 设备。DeviceCheck 提供每台设备专属的位,用于设置简单标记(例如“已领取促销优惠”)。App Attest 使用 Secure Enclave 密钥和 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
    }
}
服务器端概述

服务器会将每个新令牌提交给经过身份验证的 DeviceCheck API。加载 DeviceCheck 服务器端点,查看端点和环境详情。

两个位的用途

Apple 会为每台设备、每个开发者团队存储两个布尔值。它们的含义由你决定。常见用途:

  • 位 0: 设备已领取促销优惠。
  • 位 1: 设备已被标记为存在欺诈行为。

重新安装应用后,这些位仍会保留。你可以通过服务器 API 控制何时重置它们。

DCAppAttestService(App Attest)

DCAppAttestService 用于验证特定设备上的特定应用实例是否合法。它会使用 Secure Enclave 中由硬件保护的密钥创建加密证明和断言。iOS 14 及以上版本可用。

此流程分为三个阶段:

  1. 密钥生成 -- 在 Secure Enclave 中创建密钥对。
  2. 证明 -- Apple 认证该密钥属于运行你应用的真实 Apple 设备。
  3. 断言 -- 使用经过证明的密钥为服务器请求签名,证明应用仍然合法。
检查支持情况
import DeviceCheck

let attestService = DCAppAttestService.shared

guard attestService.isSupported else {
    // Fall back to DCDevice token or other risk assessment.
    // App Attest is not available on simulators or all device models.
    return
}

对于应用扩展,App Attest 仅支持 Action、extensible SSO 和 watchOS 扩展。即使 isSupported 返回 true,其他扩展类型也应视为不受支持。

App Attest 密钥生成

每个设备上的每个用户帐户生成一个密钥对。私钥保留在 Secure Enclave 中。返回的 keyId 是应用之后访问该密钥的唯一标识符,因此应记录并重复使用帐户和设备专属的 keyId;不要在多个用户之间共享同一个密钥。避免不必要地重新生成密钥,因为每个新密钥都会影响 App Attest 密钥数量风险指标。只有在服务器验证证明后,才能将 keyId 视为可用。如果服务器验证失败,请丢弃 keyId,并在重试前生成新密钥。

import DeviceCheck

actor AppAttestManager {
    private let service = DCAppAttestService.shared
    private var keyId: String?

    /// Generate and record a key pair for 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 helpers (simplified)

    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) // Remove old if exists
        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 {
    /// Attest the key with Apple. Send the attestation object to your server.
    func attestKey() async throws -> Data {
        guard let keyId else {
            throw DeviceIntegrityError.keyNotGenerated
        }

        // 1. Request a one-time challenge from your server
        let challenge = try await fetchServerChallenge()

        // 2. Hash the challenge (Apple requires a SHA-256 hash)
        let challengeHash = Data(SHA256.hash(data: challenge))

        // 3. Ask Apple to attest the key
        let attestation = try await service.attestKey(keyId, clientDataHash: challengeHash)

        // 4. Send the attestation object to your server for verification
        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 断言流程

完成证明后,使用断言对敏感请求进行签名。每个断言 都能证明请求来自已证明的 App 实例,并包含 服务端签发的一次性质询,以防止重放。

客户端断言
import DeviceCheck
import CryptoKit

extension AppAttestManager {
    /// Generate an assertion for encoded client data.
    /// Client data should include a one-time server challenge and request context.
    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 {
    /// Perform an attested API request.
    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 可证明特定请求的 App 实例完整性。它不能 替代用户身份验证、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,了解完整的集成管理器模式、渐进式推出指南和错误类型定义。

常见错误

  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 环境权限与沙盒/生产服务器路由保持一致
  • 已考虑逐步推出;已设置用于启用/禁用功能的功能标志

参考资料

评论

支持 Markdown