先看结论与判断条件
- 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 状态;若服务端通过而业务拒绝,问题位于上层授权策略。这样的状态链能减少盲目调整保护规则。
| 阶段 | 客户端证据 | 服务端证据 | 不能直接推出 |
|---|---|---|---|
| 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 摘要和关联号,不记录可重用原值,避免诊断系统变成重放材料库。
| 状态 | 服务端条件 | 预期判定 | 证据 |
|---|---|---|---|
| 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、生产会话或内部地址。
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 状态、请求摘要、后端回执和设备矩阵进入御盾中央平台。没有同一候选的真实回执不得登记兼容通过。
| 证据方 | 必须绑定 | 可确认 | 不能单独确认 |
|---|---|---|---|
| 设备端 | 候选与关联号 | 客户端阶段 | 服务端验证通过 |
| 服务端 | 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 状态、请求摘要契约、服务端验证回执、重放与重装用例以及设备矩阵。