WWDC Quick Look 💓 By SwiftGGTeam
Mitigate fraud with App Attest and DeviceCheck

Mitigate fraud with App Attest and DeviceCheck

观看原视频

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,给后续断言和风险评估使用。

证明失败也不一定代表攻击。演讲列出的正常失败包括 isSupportedfalse、灰度期间被限流、网络失败。上线时应该逐步扩大覆盖范围,避免大量安装同时调用 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 密钥也会失效。

核心启发

  1. 做什么:给新手礼包增加“每设备一次”的限制。 为什么值得做:DeviceCheck 的两个 bit 会跨重装保留,能覆盖反复卸载安装领取奖励的场景。 怎么开始:用 DCDevice.current.generateToken 把设备 token 发给服务器,服务器用 DeviceCheck API 查询和更新“已领取”bit。

  2. 做什么:保护兑换码、积分提现吗、稀有道具领取接口。 为什么值得做:App Attest 断言能把 payload、服务端 challenge 和设备密钥绑定,降低改包客户端伪造请求的风险。 怎么开始:用 DCAppAttestService.generateKey 建密钥,attestKey 完成服务端登记,敏感接口调用前用 generateAssertion 生成断言。

  3. 做什么:为排行榜提交加入客户端完整性信号。 为什么值得做:服务端可以检查请求来自真实 Apple 设备和真实 App,再决定成绩是否直接入榜。 怎么开始:把分数 payload 和一次性 challenge 做哈希,调用 generateAssertion,服务端验证签名、App identity hash 和递增 counter。

  4. 做什么:监控“不支持 App Attest”的设备比例。 为什么值得做:演讲指出比例突然变化可能代表改包 App 试图绕过检查。 怎么开始:在服务端记录 isSupported == false、证明失败、断言失败等事件,按版本、地区和设备类型观察趋势。

  5. 做什么:给 App Clip 的优惠领取做升级保护。 为什么值得做:iOS 15 支持 App Clip 使用 App Attest,App Clip 和完整 App 共享 App Attest 身份,适合保护从轻量体验到完整安装的权益链路。 怎么开始:服务端验证 App identity 时同时接受 App Clip 和完整 App 的合法身份,权益发放仍用 DeviceCheck 或账号状态去重。

关联 Session

评论

GitHub Issues · utterances