关于 device-integrity
面向 Apple 应用开发者的 DeviceCheck 设备与应用完整性指南,介绍单次使用的设备令牌,以及 App Attest 的硬件密钥和服务器端验证要求。
主要功能
- 01生成单次使用的 DeviceCheck 令牌
- 02存储和管理设备级标志位
- 03创建由 Secure Enclave 支持的 App Attest 密钥
- 04证明密钥并为请求签名
- 05在服务器验证证明和断言
适用场景
- 01记录设备级促销领取或欺诈标志
- 02在敏感请求前验证应用实例
- 03将签名请求绑定到一次性挑战
- 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 令牌)
- 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
}
}
服务器端概述
服务器会将每个新令牌提交给经过身份验证的 DeviceCheck API。加载 DeviceCheck 服务器端点,查看端点和环境详情。
两个位的用途
Apple 会为每台设备、每个开发者团队存储两个布尔值。它们的含义由你决定。常见用途:
- 位 0: 设备已领取促销优惠。
- 位 1: 设备已被标记为存在欺诈行为。
重新安装应用后,这些位仍会保留。你可以通过服务器 API 控制何时重置它们。
DCAppAttestService(App Attest)
DCAppAttestService 用于验证特定设备上的特定应用实例是否合法。它会使用 Secure Enclave 中由硬件保护的密钥创建加密证明和断言。iOS 14 及以上版本可用。
此流程分为三个阶段:
- 密钥生成 -- 在 Secure Enclave 中创建密钥对。
- 证明 -- Apple 认证该密钥属于运行你应用的真实 Apple 设备。
- 断言 -- 使用经过证明的密钥为服务器请求签名,证明应用仍然合法。
检查支持情况
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,了解完整的集成管理器模式、渐进式推出指南和错误类型定义。
常见错误
- 每次启动都生成新密钥。 每台设备上的每个用户账户只生成一次,持久化
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 环境权限与沙盒/生产服务器路由保持一致
- 已考虑逐步推出;已设置用于启用/禁用功能的功能标志