先看结论与判断条件

  • App Attest 的信任判定发生在服务端,客户端生成 key、attestation 或 assertion 成功只能证明流程走到某一步。
  • 测试前固定应用身份、签名候选、环境、设备、账号、服务端版本和功能开关,只让是否加固这一变量变化。
  • 服务端 challenge 必须一次一用、绑定会话与用途,并有明确过期和消费状态,不能只比较字符串相等。
  • 请求摘要要基于确定字节序列计算;JSON 若没有规范化规则,字段顺序和数字表示可能让两端得到不同摘要。
  • 注册证明与后续断言分开验收,重放、计数异常、错配应用身份、重装和密钥失效都要有独立判定码。
  • 代码签名、App Attest 信号和业务授权是三个边界,任何一项通过都不能替代服务端对当前操作的风险决定。

先把 App Attest 拆成注册、请求和判定三条证据

App Attest 的基本边界不是客户端执行一次系统 API 后返回成功,而是服务端能够验证来自应用实例的证明,并把后续断言与当前业务请求关联。回归要把注册 key、服务端 challenge、attestation 验证、key 记录、请求摘要、assertion 验证和业务判定分成独立阶段。任何阶段缺少服务端回执,都只能记录客户端现象,不能登记信任已建立。

Apple App Attest server validation 明确把验证责任放在服务端,并要求把证明与请求关联。平台证明可以作为风险信号,却不是绝对设备可信、用户身份或业务授权。服务端还需要检查当前账号、会话、操作、频率和其他风险输入。测试报告应把 App Attest verdict 与最终业务 decision 分开,避免一个成功回调自动放行高价值操作。

排查从第一个不同状态开始。若 key 创建失败,观察客户端能力和系统错误;若 attestation 已生成但服务端拒绝,查看 challenge、应用身份和证明验证;若注册通过但 assertion 失败,比较请求摘要、计数、会话与 key 状态;若服务端通过而业务拒绝,问题位于上层授权策略。这样的状态链能减少盲目调整保护规则。

App Attest 回归的状态链
阶段客户端证据服务端证据不能直接推出
key 创建key 标识关联号尚无信任实例已可信
challenge接收与用途签发消费状态请求已通过
attestation生成结果类别证明验证结果业务已授权
key 注册注册完成回调身份和 key 记录后续断言有效
assertion请求与断言发送摘要和计数验证用户身份正确
业务判定用户看到的结果风险与授权决定所有攻击被阻断

建立只差加固步骤的签名候选基线

未加固与加固候选应来自同一源码提交、构建变体、应用标识、entitlement、签名团队、服务端环境和功能开关。两个候选分别计算摘要,并记录生成时间、签名身份与安装路径。若基线包是开发签名,加固包是发布签名,App Attest 环境或应用身份可能已不同,结果差异不能归到保护逻辑。

Apple app code signing process 说明 Apple 平台使用代码签名、证书与运行验证建立 App 身份。代码签名是 App Attest 应用身份链的重要背景,但它不等同服务端证明,也不能替代 challenge、请求摘要和断言验证。回归报告同时保存候选摘要、签名团队和应用标识的公开身份信息,不保存私钥、证书口令或可用凭据。

设备与服务端也必须固定。记录设备型号、系统版本、是否支持当前能力、安装或重装步骤、测试账号的脱敏标识、后端版本、时间同步和缓存状态。每轮只改变一个变量,并为服务端关联号标记候选摘要。若后端在实验期间部署新规则,整轮结果应标记受污染并重做,不能用不同服务版本拼接对照。

单变量候选基线
固定项未加固候选加固候选不一致处理
源码提交提交摘要同一摘要停止比较
应用身份团队与应用标识同一身份修复签名链
entitlement批准清单同一清单重建候选
服务端版本部署摘要同一部署冻结后端
设备账号固定组合同一组合拆分环境变量
安装状态按脚本清理同一步骤重新执行基线

challenge 必须绑定会话、用途和一次消费状态

challenge 的作用不是给客户端一个可复制字符串,而是把证明或断言绑定到服务端当前发起的交互。服务端签发时记录不可预测值的摘要、会话、应用身份、用途、创建时间、过期时间和消费状态。客户端只接收当前操作所需的 challenge;服务端验证后原子地标记已消费,重复提交应进入 replay 类别,而不是再次获得同一结果。

回归至少覆盖正常使用、过期、错误会话、错误应用身份、错误用途、重复提交和并发消费。重复失败要证明服务端拒绝发生在消费状态,而不是客户端偶然没有再次发送。并发测试应让两次请求争用同一 challenge,最终只能有一条合法消费记录。具体过期窗口由业务风险决定,文章不设统一时长。

challenge 错配与网络失败需要分开。客户端拿到 challenge 后离线可能导致请求未到达,服务端仍可保持未消费直至过期;服务端已消费但客户端未收到响应,则后续恢复要使用新的 challenge 和幂等业务策略,不能重新接受旧证明。日志使用 challenge 摘要和关联号,不记录可重用原值,避免诊断系统变成重放材料库。

challenge 状态机测试
状态服务端条件预期判定证据
issued已签发未使用允许首次验证签发回执
consumed验证成功后消费拒绝再次提交消费事务
expired超过批准窗口终态拒绝服务端时间
wrong-session会话不匹配终态拒绝会话摘要
wrong-purpose操作类型不匹配终态拒绝用途字段
race并发争用一次值仅一条消费成功事务顺序

请求摘要要基于双方一致的确定字节序列

assertion 与业务请求绑定时,客户端和服务端必须对相同字节计算摘要。若直接对普通 JSON 文本取哈希,属性顺序、空白、Unicode 表示和数字序列化差异都可能产生不同结果。团队应明确哪些字段进入摘要、字段类型、缺失与空值的区别、编码、规范化方式和版本,并在测试夹具中保留公开安全的期望摘要。

RFC 8785 JSON Canonicalization Scheme 为 JSON 提供确定性的属性排序、字符串和数字序列化规则。JCS 可以帮助两端重算同一字节序列,但它不提供认证、重放控制、App Attest verdict 或业务授权。即使 canonical JSON 摘要一致,服务端仍要验证 assertion、challenge、应用身份、key 状态和当前会话。

加固可能影响模型序列化、字段可见性、反射保留或数据编码,回归应在摘要前保存字段类型与规范化版本,而不是记录完整敏感请求。使用一组公开夹具覆盖字段顺序变化、Unicode、整数、小数、空值、额外字段和版本升级。若只在加固候选出现摘要差异,逐项比较规范化输入和序列化路径,再决定保护范围。

请求摘要契约
字段契约要求错误示例回归证据
字段集合明确白名单和版本随对象自动扩展schema 版本
属性顺序规范化排序依赖运行时顺序canonical 文本摘要
字符串统一 Unicode 规则两端隐式转换公开夹具
数字固定合法表示浮点随意格式化类型与期望值
空值区分缺失和 null统一当空字符串边界用例
敏感数据只留摘要与关联号记录完整请求脱敏日志样本

注册证明与后续断言需要两套验收条件

注册阶段验证新的 App Attest key 与应用身份,并把服务端签发的 challenge 和证明绑定。通过后,服务端保存 key 标识、应用身份、环境、创建状态和必要验证元数据。客户端成功生成 attestation 只说明本地调用完成;没有服务端验证结果、注册事务和后续 key 查找记录,不能声称注册成功。

断言阶段针对具体业务请求验证当前 key、请求摘要、计数与服务端上下文。它不应重复注册阶段的宽泛结论,而要回答这次请求是否由已登记 key 产生、是否绑定当前数据、是否出现计数或重放异常,以及服务端最终如何处理。注册通过后客户端重装、key 丢失或服务端记录撤销,都需要明确恢复路径。

测试矩阵包含首次注册、重复注册请求、已注册 key 的正常断言、错误 key、错误应用身份、错误请求摘要、计数异常、服务端撤销和客户端重装。每一项要有不同诊断码,避免所有失败都显示证明无效。诊断码是排查证据,不应向客户端暴露可帮助试探验证细节的信息。

重放、计数异常与重装要按终态和恢复路径处理

重复 challenge、重复 assertion、相同请求摘要和异常计数反映的风险不同。服务端先判断 challenge 是否已消费,再验证 assertion 与请求,最后检查 key 的计数或状态。重放和明确错配属于安全终态,不应被普通网络重试掩盖;网络超时属于传输未决,恢复时使用新的 challenge,并由业务幂等机制处理可能已经完成的操作。

重新安装可能导致本地 App Attest key 生命周期发生变化,因此测试不应假设旧 key 永远可用。重装路径需要明确服务端如何识别新的注册、旧 key 是否撤销或保留、同一账号下多个实例如何管理,以及风险策略何时要求额外验证。没有服务端状态迁移回执时,客户端重新拿到 key 标识也不能证明历史关系已正确处理。

RFC 9700 OAuth Security BCP 讨论令牌重放、授权码注入和不安全授权流程。它不是 App Attest 规范,但支持一个通用边界:可重放凭据、重定向和会话绑定需要服务端约束。本文只借其说明重放与绑定风险,不把 OAuth grant、令牌或商业权限模型套到 App Attest 上。实际判定仍以 Apple 文档和项目服务端实现为准。

异常分类与恢复动作
异常判定类别恢复动作禁止做法
网络超时传输未决新 challenge 与幂等查询重用旧证明
challenge 重放安全终态拒绝并记录关联号当作偶发错误
摘要错配请求绑定失败拒绝并比较规范化忽略差异放行
计数异常key 风险状态按策略隔离调查客户端自行重置
key 撤销服务端终态按批准流程重注册悄悄恢复旧记录
客户端重装实例迁移建立新注册关系假设旧 key 存在

代码签名、供应链身份与运行证明不能互相替代

代码签名确认 Apple 平台上的应用身份和完整性检查链,App Attest 为服务端提供应用实例与请求相关信号,供应链证明则把构建产物与构建上下文关联。三者回答不同问题。一个候选签名有效,不代表 assertion 绑定正确;App Attest 服务端验证通过,不代表该构建来源和加固配置已被审计。

in-toto Attestation Statement v1 使用 subject 摘要和有类型的 predicate 绑定产物与声明。项目可借鉴这种结构,让设备回执和后端结果都引用同一候选摘要,但声明格式不保证事实真实。NIST SP 800-218 SSDF 又强调来源、构建、验证和变更证据。回归包应保存候选、配置、测试和修复的身份链,避免测试了一个包却发布另一个包。

没有真实项目回执时,只能说明应该收集哪些证据,不能宣称加固后 App Attest 已兼容、攻击已阻断或请求性能提升。若保护规则、构建配置、签名身份、entitlement 或服务端验证代码发生变化,都要生成新候选并重新执行相关矩阵。历史通过记录只适用于其绑定的候选和条件。

用代码检查公开测试回执的一一绑定关系

下面的 Python 示例读取公开安全的 App Attest 测试夹具,按确定 JSON 表示计算请求摘要,并检查 challenge、应用身份、会话、候选、key、assertion 和服务端 verdict 是否齐全。它在单个文件中拒绝重复 challenge、重复 assertion、重复会话用途以及摘要错配。示例不生成真实证明、不验 Apple 密码学结构、不包含私钥或绕过方法。

夹具里的 request 是合成业务字段,expectedDigest 是预先批准的测试值。代码使用排序键和紧凑分隔符建立稳定演示输入,但完整 JCS 数字和 Unicode 规则应采用合适实现,不能把这段教学代码宣称为 RFC 8785 完整实现。服务端回执只允许 passed 或 rejected,并要求 passed 记录同时具有 challengeConsumed 和 assertionVerified。

静态校验通过仅表示夹具内部一致,不证明 Apple 服务端验证真实发生。实际项目仍要验证 attestation 和 assertion 的密码学数据、应用身份、环境、key 状态与计数,并把结果写入不可混淆的后端回执。测试夹具不应含真实用户数据、完整 challenge、生产会话或内部地址。

校验 App Attest 回归夹具的请求绑定
import hashlib
import json
import re
import sys
from pathlib import Path

REQUIRED = {
    'applicationId',
    'artifactSha256',
    'sessionRef',
    'purpose',
    'challengeRef',
    'keyRef',
    'assertionRef',
    'request',
    'expectedDigest',
    'challengeConsumed',
    'assertionVerified',
    'serverVerdict',
}
FORBIDDEN = {'privateKey', 'rawAttestation', 'rawAssertion', 'token', 'userData'}
SHA256 = re.compile(r'^[a-f0-9]{64}$')
VERDICTS = {'passed', 'rejected'}

def stop(message):
    raise SystemExit(message)

def stable_digest(value):
    body = json.dumps(value, sort_keys=True, separators=(',', ':'), ensure_ascii=False)
    return hashlib.sha256(body.encode('utf-8')).hexdigest()

def validate(record, index):
    if not isinstance(record, dict):
        stop(f'record {index} is not an object')
    if FORBIDDEN.intersection(record):
        stop(f'record {index} contains forbidden fields')
    missing = sorted(REQUIRED - record.keys())
    if missing:
        stop(f'record {index} lacks fields: {missing}')
    if not SHA256.fullmatch(str(record['artifactSha256'])):
        stop(f'record {index} has invalid artifact digest')
    if not isinstance(record['request'], dict):
        stop(f'record {index} request is not an object')
    actual = stable_digest(record['request'])
    if actual != record['expectedDigest']:
        stop(f'record {index} request digest mismatches')
    if record['serverVerdict'] not in VERDICTS:
        stop(f'record {index} has invalid server verdict')
    if record['serverVerdict'] == 'passed':
        if record['challengeConsumed'] is not True:
            stop(f'record {index} passed without challenge consumption')
        if record['assertionVerified'] is not True:
            stop(f'record {index} passed without assertion verification')
    identity = (record['sessionRef'], record['purpose'])
    return record['challengeRef'], record['assertionRef'], identity

if len(sys.argv) != 2:
    stop('usage: validate_app_attest_fixture.py fixture.json')
source = Path(sys.argv[1])
if not source.is_file():
    raise SystemExit('fixture file is missing')
data = json.loads(source.read_text(encoding='utf-8'))
records = data.get('records') if isinstance(data, dict) else None
if not isinstance(records, list) or not records:
    raise SystemExit('fixture records are missing')
results = [validate(record, index) for index, record in enumerate(records)]
challenges = [item[0] for item in results]
assertions = [item[1] for item in results]
identities = [item[2] for item in results]
if len(challenges) != len(set(challenges)):
    raise SystemExit('fixture reuses a challenge reference')
if len(assertions) != len(set(assertions)):
    raise SystemExit('fixture reuses an assertion reference')
if len(identities) != len(set(identities)):
    raise SystemExit('fixture duplicates session and purpose')
print(json.dumps({'records': len(records), 'status': 'pass'}))

用设备、服务端和候选三方回执完成闭环

设备端回执记录候选摘要、应用身份、key 操作阶段、客户端错误类别和关联号;服务端回执记录 challenge 签发消费、attestation 或 assertion 验证、请求摘要、key 状态、计数与最终 verdict;候选回执记录构建来源、签名、entitlement 和加固配置身份。三方用同一关联号和候选摘要连接,不能用客户端截图替代服务端验证。

矩阵至少覆盖首次注册、正常断言、重复 challenge、重复 assertion、错误摘要、错误会话、过期、计数异常、服务端撤销、重装、网络超时和业务拒绝。每个用例写清期望终态、恢复动作与是否允许重试。单一设备通过不能代表所有系统和硬件组合,最终报告要列出真实覆盖范围与未测项。

修复后生成新候选,并在相同后端版本和设备条件下重复失败点及相邻安全失败路径。需要继续排查其他加固回归,可阅读本站的 Google Play 许可校验回归方法了解服务端绑定思路;需要提交项目验证,可携带候选、签名身份、challenge 状态、请求摘要、后端回执和设备矩阵进入御盾中央平台。没有同一候选的真实回执不得登记兼容通过。

App Attest 闭环证据
证据方必须绑定可确认不能单独确认
设备端候选与关联号客户端阶段服务端验证通过
服务端challenge key 与摘要验证和最终 verdict设备实际用户体验
候选身份摘要签名与配置测试对象身份请求绑定正确
请求夹具schema 和期望摘要两端字节契约真实请求已授权
设备矩阵系统硬件和场景已测组合表现未测组合通过
修复回执新候选和变更原失败点状态其他风险不存在

事实依据与适用边界

以下内容区分官方事实、本文工程判断和不能外推的范围,避免把设计建议写成未经验证的产品结论。

本文判断事实或工程依据适用限制
App Attest 证明需要在服务端验证并绑定请求,不能只在客户端做信任判断。Apple App Attest server validation 描述验证连接服务端应用的证明流程。平台证明是风险信号,不是绝对设备可信、用户身份或业务授权。
Apple 平台通过代码签名、证书与运行验证建立 App 身份。Apple app code signing process 说明 Apple 平台的应用签名与验证过程。代码签名链不替代 App Attest 服务端验证,也不能与 Android APK 签名混为一套证据。
JSON 摘要输入需要确定的属性排序、字符串和数字序列化规则。RFC 8785 JSON Canonicalization Scheme 定义 JSON 规范化表示。JCS 只解决确定性表示,不提供认证、重放控制、App Attest verdict 或业务授权。
可重放凭据、重定向和会话绑定需要服务端安全约束。RFC 9700 OAuth Security BCP 讨论令牌重放、授权码注入和不安全授权流程。OAuth 安全 BCP 不是 App Attest 规范,也不决定具体应用的商业权限模型。
供应链声明应把产物摘要与有类型的声明负载绑定。in-toto Attestation Statement v1 定义 subject、predicateType 与 predicate 的结构。声明格式不保证内容真实,仍需可信签名者、门禁和候选摘要复算。
安全发布应保留来源、构建、验证和变更证据,并处理供应链风险。NIST SP 800-218 SSDF 提供组织级安全软件开发实践。SSDF 不定义某个 App 加固产品功能,也不提供 App Attest 兼容结论。
App Attest 回归应固定候选、应用身份、会话、challenge、请求摘要和后端版本后做单变量比较。工程判断:固定变量才能把差异收敛到客户端、签名、绑定或服务端验证边界。对照一致只支持定位,不证明保护强度、攻击阻断或全部设备兼容。
客户端回调、服务端证明 verdict 与业务授权决定应分别记录。工程判断:三者的执行位置、证据和责任不同,合并会产生越权信任。具体风险阈值、恢复动作和用户提示由项目策略决定,文章不设统一数值。

工程常见问题

加固后客户端成功生成 App Attest assertion,是否就算回归通过?

不算。还要由服务端验证 challenge、应用身份、请求摘要、key 状态和计数,并给出与当前会话及操作绑定的 verdict,客户端回调只证明流程到达局部阶段。

代码签名有效是否等于 App Attest 证明有效?

不等于。代码签名建立平台应用身份,App Attest 还需要服务端验证应用实例和当前请求,两条证据相关但不能互相替代。

为什么客户端和服务端会算出不同的请求摘要?

常见原因包括字段集合、属性顺序、Unicode、数字表示、空值和编码不一致。双方需要明确 schema 与规范化规则,并用公开夹具核对确定字节序列。

网络超时后可以重复提交同一个 challenge 和 assertion 吗?

不应默认重复使用。服务端可能已经消费 challenge 或完成操作,恢复应使用新的 challenge,并通过业务幂等查询确认先前状态,避免重放。

重新安装 App 后旧的 App Attest key 应怎样处理?

不要假设旧 key 永久存在。测试需要验证新注册、旧 key 撤销或保留、账号下多实例管理及风险策略,并以服务端状态迁移回执为准。

向御盾中央平台提交 App Attest 回归验证需要什么材料?

准备未加固和加固候选摘要、签名身份、应用标识、entitlement、challenge 状态、请求摘要契约、服务端验证回执、重放与重装用例以及设备矩阵。

想用自己的 App 验证?

提交候选包、目标系统和关键业务路径,申请御盾 PoC 与兼容性评估。

继续阅读: App 加固 PoC 如何形成发布结论