使用 DeviceCheck(DCDevice 每设备位)和 App Attest(DCAppAttestService 密钥生成、证明和断言流程)验证设备合法性和应用完整性。适用于实现欺诈预防、检测受损设备、通过 Apple 服务器验证应用真实性、使用已证明请求保护敏感 API 端点,或为后端架构添加设备验证。
设备完整性
验证发往服务器的请求是否来自运行您应用合法实例的正版 Apple 设备。DeviceCheck 提供每设备位用于简单标记(例如“已领取促销优惠”)。App Attest 使用安全隔区密钥和 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
}
}
服务器端概述
服务器将每个新令牌与 Apple 的已验证 DeviceCheck API 交换。加载 DeviceCheck 服务器端点 以获取端点和环境详细信息。
两个位的用途
Apple 为每个开发者团队、每台设备存储两个布尔值。您决定它们的含义。常见用途:
- 位 0: 设备已领取促销优惠。
- 位 1: 设备已被标记为欺诈。
位在应用重新安装后仍然存在。您通过服务器 API 控制何时重置它们。
DCAppAttestService(App Attest)
DCAppAttestService 验证特定设备上特定应用实例的合法性。它使用安全隔区中硬件支持的密钥创建密码学证明和断言。适用于 iOS 14+。
流程分为三个阶段:
- 密钥生成——在安全隔区中创建密钥对。
- 证明——Apple 证明该密钥属于运行您应用的正版 Apple 设备。
- 断言——使用已证明的密钥对服务器请求进行签名,以证明持续合法性。
检查支持
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— 格式错误的clientDataHash或keyId
对于 attestKey,稍后使用相同的 keyId 和相同的 clientDataHash 重试 .serverUnavailable。对于其他证明错误,丢弃密钥标识符并在重试前创建新密钥。请参阅 references/device-integrity-patterns.md 获取完整的错误处理代码、重试策略和已拒绝密钥恢复。
常见模式
环境授权
在您的授权文件中设置 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 环境授权和沙盒/生产服务器路由一致
- [ ] 考虑逐步推出;存在用于启用/禁用的功能标志






