Highlight
Apple 用 DeviceCheck 和 App Attest 提供设备级促销状态、应用完整性验证和请求签名能力,让服务端能识别重复领取、改包客户端和被篡改的敏感请求。
核心内容
一个常见的促销活动,是给新功能发一个免费道具。问题出在安装包本身。用户可以卸载 App,再安装一次,再领一次。你的账号系统也许能挡住一部分滥用,但同一台设备换账号、清数据、重装应用,仍然会绕过很多简单规则。
DeviceCheck 解决的是这类促销状态问题。它允许开发者在 Apple 服务器上为一台设备保存两个 bit 和一个时间戳。这份状态会跨 App 重装、设备转移,甚至跨“抹掉所有内容和设置”保留。开发者自己决定 bit 的含义,比如“已经领过新手礼包”或“参与过欺诈活动”。
另一个问题更难。服务端收到一个请求,很难确认它来自正版 App。改包客户端可以伪造地理位置,在旅行收集类应用里原地拿完所有藏品;游戏外挂可以给自己无限加速;脚本也可以直接打你的接口,让服务器误以为流量来自真实用户。
App Attest 解决的是请求来源和请求内容问题。它让 App 创建一把由硬件保护的密钥,Apple 为这把密钥出具证明。服务端验证后,可以知道这把密钥来自真实 Apple 设备、属于你的真实 App,并且后续请求的 payload 没被中途改掉。
这套机制的边界也很清楚。App Attest 的密钥按 App 安装生成,不会跨重装保留,也不会备份或同步。DeviceCheck 更适合记录设备级历史状态。App Attest 更适合保护重要但不高频的服务端请求。
详细内容
DeviceCheck:在 Apple 服务器保存设备级状态
(00:50)DeviceCheck 是 iOS 11 引入的框架,用来缓解促销活动滥用。它可以在 Apple 服务器上保存与一台设备关联的两个 bit 和一个时间戳。Apple 负责维护状态,开发者负责定义状态含义。
import DeviceCheck
let device = DCDevice.current
if device.isSupported {
device.generateToken { token, error in
guard let token = token, error == nil else {
// Handle the error.
return
}
// Send token to your server.
}
} else {
// Treat as a risk signal in your fraud assessment.
}
关键点:
import DeviceCheck引入 DeviceCheck 框架。DCDevice.current取得当前设备的 DeviceCheck 入口。device.isSupported先判断设备是否支持 DeviceCheck。generateToken生成设备 token,App 把它发给自己的服务器。- 服务器拿 token 调用 Apple 的 DeviceCheck 服务,查询或更新这台设备的两个 bit。
- 不支持或生成失败时,不要直接放行敏感权益,应把失败作为风控信号。
这两个 bit 由同一开发者账号下的所有 App 共享。设计含义时要覆盖整个产品线,不能只按单个 App 的局部逻辑命名。时间戳可以用来做周期性重置,比如每个活动周期重新允许领取一次。
App Attest 第一步:创建硬件保护的密钥
(08:02)App Attest 属于 DeviceCheck 框架。它的核心是一对安全密钥。私钥只能通过 App Attest API 在 Secure Enclave(安全隔区)里访问。服务端后续用公钥验证请求签名。
let appAttestService = DCAppAttestService.shared
if appAttestService.isSupported {
appAttestService.generateKey { keyId, error in
guard error == nil else { /* Handle the error. */ }
// Cache keyId for subsequent operations.
}
} else {
// Handle fallback as untrusted device
}
关键点:
DCAppAttestService.shared取得 App Attest 服务对象。appAttestService.isSupported必须先检查支持情况。- App Attest 需要带 Secure Enclave 的设备,但 App Extension 等场景仍可能返回
false。 generateKey在设备上创建 App Attest 密钥。keyId是后续证明密钥和生成断言的索引,App 需要缓存它。- 不支持时,Apple 建议先把调用方归类为不受信任,再交给整体风控逻辑判断。
这里不要把 isSupported == false 当成唯一封禁条件。演讲建议监控“不支持 App Attest”的比例。如果比例突然下降,可能是改包 App 试图绕过检查。
App Attest 第二步:证明密钥属于真实设备和真实 App
(09:17)密钥创建后,还不能直接信任。服务端先发一个一次性 challenge。App 把 challenge、用户账号 ID 或其他业务值一起哈希,得到 clientDataHash。这个哈希把证明过程绑定到当前业务上下文,防止中间人和重放攻击。
appAttestService.attestKey(keyId, clientDataHash: clientDataHash) { attestationObject, error in
guard error == nil else { /* Handle error. */ }
// Send the attestation object to your server for verification.
}
关键点:
keyId来自前一步generateKey。clientDataHash由服务端 challenge 和业务数据哈希得到。attestKey使用私钥创建硬件证明请求,并提交给 Apple 验证。- Apple 验证后返回匿名的
attestationObject。 - App 把
attestationObject和业务 payload 发给自己的服务器。 - 服务器验证通过后,保存这把 App Attest 密钥,用来校验后续请求。
(10:22)服务端验证的是 Web Authentication 标准格式。证明对象包含三类内容:Apple 签名的证书链、Authenticator Data(认证器数据)和 risk metric receipt(风险指标收据)。
struct AppAttestVerificationInput {
let attestationObject: Data
let clientDataHash: Data
let expectedAppIdentifier: String
let keyId: String
}
func verifyAttestation(_ input: AppAttestVerificationInput) throws {
// 1. Validate the certificate chain against the App Attest root certificate.
// 2. Reconstruct the nonce from clientDataHash and verify it in the leaf certificate.
// 3. Validate the app identity hash in authenticator data.
// 4. Store the public key and risk metric receipt for later requests.
}
关键点:
attestationObject是 App 发来的证明对象。clientDataHash必须和 App 调用attestKey时使用的哈希一致。expectedAppIdentifier代表服务端期望的 App 身份。- 第一步验证证书链,根证书来自 Apple Private PKI repository。
- 第二步重建 nonce,并确认它出现在 leaf certificate(叶证书)中。
- 第三步检查 Authenticator Data 里的 App identity hash,确认请求来自你的 App。
- 第四步保存公钥和 risk metric receipt,给后续断言和风险评估使用。
证明失败也不一定代表攻击。演讲列出的正常失败包括 isSupported 为 false、灰度期间被限流、网络失败。上线时应该逐步扩大覆盖范围,避免大量安装同时调用 attestKey 触发限流。
App Attest 第三步:给敏感请求生成断言
(13:14)完成密钥证明后,后续请求使用 generateAssertion。这个过程不再访问 Apple 服务器。断言在设备上生成,服务端用前面保存的公钥验证。
appAttestService.generateAssertion(keyId, clientDataHash: clientDataHash) { assertionObject, error in
guard error == nil else { /* Handle error. */ }
// Send assertion object with your data to your server for verification
}
关键点:
- 请求前,服务器仍然先发一个唯一 challenge。
- App 对 payload 和 challenge 生成摘要,作为
clientDataHash。 generateAssertion用 App Attest 私钥签名这个摘要。assertionObject要和原始业务数据一起发给服务器。- 服务器重建 nonce,用保存的公钥验证签名。
- 签名有效时,服务端可以信任 payload 没被篡改。
(14:09)断言对象包含签名和 Authenticator Data。服务端还要检查 App identity hash,并保存一个递增 counter。下一次请求的 counter 应该变大,用来降低重放攻击风险。
struct AppAttestAssertionInput {
let assertionObject: Data
let payload: Data
let challenge: Data
let storedPublicKey: Data
let previousCounter: UInt32
}
func verifyAssertion(_ input: AppAttestAssertionInput) throws {
// 1. Recreate clientDataHash from payload and challenge.
// 2. Reconstruct the nonce from clientDataHash.
// 3. Verify the signature with the stored public key.
// 4. Validate the app identity hash in authenticator data.
// 5. Confirm the counter is greater than previousCounter, then store it.
}
关键点:
payload是要保护的业务数据,例如领奖、兑换、上传成绩。challenge是服务端为这次请求生成的一次性值。storedPublicKey来自前面的密钥证明阶段。previousCounter是服务端保存的上一轮计数器。- 签名验证确认 payload 和 challenge 没被替换。
- App identity hash 验证确认断言来自你的 App。
- counter 递增检查用于发现重复提交旧断言。
断言是密码学操作,会增加延迟。演讲建议把它用在重要但低频的调用,例如兑换、领取、提交关键成绩。实时高频网络命令不适合每次都生成断言。
Risk Metric Service:发现一台设备批量制造密钥
(16:04)攻击者可能用一台真实设备创建很多 App Attest 密钥,再把这台设备当作中转,为许多改包 App 提供通信。App Attest Risk Metric Service 用来发现这类模式。
struct RiskMetricRecord {
let keyId: String
let receipt: Data
let approximateKeyCount: Int?
}
func updateRiskMetric(_ record: RiskMetricRecord) {
// Submit the latest receipt to the App Attest Risk Metric Service.
// Store the new receipt and the approximate number of keys for this app/device pair.
}
关键点:
attestKey返回的证明里包含 risk metric receipt。- 服务器保存这份 receipt。
- 后续服务器把 receipt 提交给风险指标服务,换取新的 receipt。
- 新 receipt 会包含这台设备为你的 App 创建的大致密钥数量。
- 服务器可以周期性兑换最新 receipt,更新同一 app/device pair 的风险信号。
这个指标适合进入风控评分。它不替代业务规则。设备上密钥数量异常时,可以要求额外验证、降低奖励额度,或延迟发放敏感权益。
App Clips:App Clip 和完整 App 共用 App Attest 身份
(17:17)iOS 15 为 App Clips 加入 App Attest 支持。为了让 App Clip 顺利升级到完整 App,App Clip 和完整 App 在 App Attest 上下文中共享同一个 App identity。
struct AppIdentityPolicy {
let fullAppIdentifier: String
let appClipIdentifier: String
func accepts(_ attestedAppIdentity: String) -> Bool {
attestedAppIdentity == fullAppIdentifier || attestedAppIdentity == appClipIdentifier
}
}
关键点:
fullAppIdentifier表示完整 App 的身份。appClipIdentifier表示 App Clip 的身份。- 服务端验证 App identity 时要把 App Clip 升级路径考虑进去。
- 手动移除或过期的 App Clip 会让它的 App Attest 密钥失效。
- 完整 App 卸载时,它的 App Attest 密钥也会失效。
核心启发
-
做什么:给新手礼包增加“每设备一次”的限制。 为什么值得做:DeviceCheck 的两个 bit 会跨重装保留,能覆盖反复卸载安装领取奖励的场景。 怎么开始:用
DCDevice.current.generateToken把设备 token 发给服务器,服务器用 DeviceCheck API 查询和更新“已领取”bit。 -
做什么:保护兑换码、积分提现吗、稀有道具领取接口。 为什么值得做:App Attest 断言能把 payload、服务端 challenge 和设备密钥绑定,降低改包客户端伪造请求的风险。 怎么开始:用
DCAppAttestService.generateKey建密钥,attestKey完成服务端登记,敏感接口调用前用generateAssertion生成断言。 -
做什么:为排行榜提交加入客户端完整性信号。 为什么值得做:服务端可以检查请求来自真实 Apple 设备和真实 App,再决定成绩是否直接入榜。 怎么开始:把分数 payload 和一次性 challenge 做哈希,调用
generateAssertion,服务端验证签名、App identity hash 和递增 counter。 -
做什么:监控“不支持 App Attest”的设备比例。 为什么值得做:演讲指出比例突然变化可能代表改包 App 试图绕过检查。 怎么开始:在服务端记录
isSupported == false、证明失败、断言失败等事件,按版本、地区和设备类型观察趋势。 -
做什么:给 App Clip 的优惠领取做升级保护。 为什么值得做:iOS 15 支持 App Clip 使用 App Attest,App Clip 和完整 App 共享 App Attest 身份,适合保护从轻量体验到完整安装的权益链路。 怎么开始:服务端验证 App identity 时同时接受 App Clip 和完整 App 的合法身份,权益发放仍用 DeviceCheck 或账号状态去重。
关联 Session
- Safeguard your accounts, promotions, and content — 从业务角度介绍账号、促销和内容保护,本场补上工程落地细节。
- Apple’s privacy pillars in focus — 解释 Apple 的隐私原则,帮助理解 App Attest 匿名证明和无硬件标识符的设计。
- Secure login with iCloud Keychain verification codes — 展示登录安全的用户体验改进,可和服务端风控一起构建账号保护。
- Move beyond passwords — 介绍基于公钥凭证的账号安全方向,和 App Attest 同样依赖服务端验证公钥材料。
评论
GitHub Issues · utterances