🚀 快速安装
复制以下命令并运行,立即安装此 Skill:
npx skills add https://skills.sh/dpearson2699/swift-ios-skills/device-integrity
💡 提示:需要 Node.js 和 NPM
设备完整性
验证发送到您服务器的请求是否来自运行您未经修改应用的真正 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 端点:
| 端点 | 用途 |
|---|---|
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+。
流程分为三个阶段:
- 密钥生成 — 在安全隔区中创建密钥对。
- 认证 — Apple 证明该密钥属于运行您应用的真正 Apple 设备。
- 断言 — 使用认证过的密钥对服务器请求进行签名,以证明持续合法性。
检查支持情况
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
}
}
}
服务器端认证验证
您的服务器必须:
- 验证认证对象是一个有效的 CBOR 编码结构。
- 提取证书链并针对 Apple 的 App Attest 根 CA 进行验证。
- 验证认证中的
nonce是否匹配SHA256(challenge)。 - 提取并存储公钥和收据,以备将来断言验证使用。
有关完整的服务器验证算法,请参阅 验证连接到服务器的应用。
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)
}
}
服务器端断言验证
您的服务器必须:
- 解码断言(CBOR)。
- 验证认证者数据,包括计数器(必须大于存储的计数器)。
- 使用认证时存储的公钥验证签名。
- 确认
clientDataHash与接收到的请求体的 SHA-256 哈希匹配。 - 更新存储的计数器以防止重放攻击。
服务器端验证指南
认证 vs. 断言
| 阶段 | 时机 | 证明内容 | 频率 |
|---|---|---|---|
| 认证 | 密钥生成后 | 密钥位于运行您未经修改应用的真正 Apple 设备上 | 每个密钥一次 |
| 断言 | 每个敏感请求 | 请求来自经过认证的应用实例 | 每个请求 |
推荐的服务器架构
- 挑战端点 — 生成随机 nonce,在服务器端存储并设置短 TTL(例如 5 分钟)。
- 认证验证端点 — 验证认证对象,存储由
keyId索引的公钥和收据。 - 断言验证中间件 — 在敏感端点(购买、账户更改)上验证断言。
风险评估
将 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 中,管理完整生命周期:
- 检查
isSupported,在不支持的设备上回退到DCDevice令牌。 - 在启动时调用
generateKeyIfNeeded()以创建或加载持久化的密钥。 - 在密钥生成后调用一次
attestKeyWithRetry()。 - 在每个敏感的服务器请求上使用
generateAssertion(for:)。 - 通过重新生成和重新认证处理
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
}
常见错误
- 每次启动都生成新密钥。 生成一次,将
keyId持久化在钥匙串中。 - 跳过对不支持设备的回退。 并非所有设备都支持 App Attest。使用
DCDevice令牌作为回退。 - 在客户端信任认证结果。 所有验证必须在您的服务器上进行。
- 未实施重放保护。 服务器必须跟踪并递增断言计数器。
- 缺少环境权限。 没有它,调试构建使用
development,App Store 使用production。不匹配会导致认证失败。 - 未处理
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 原始英文文档,方便对照翻译。

评论(0)