先看结论与判断条件

  • 签名责任必须拆成身份所有者、签名执行者、候选批准者和分发上传者,四个角色可以由同一组织承担,但证据不能合并成一句口头承诺。
  • 加固前候选、加固后未签名候选和最终已签名候选是三个不同对象,必须分别计算摘要并记录转换关系,文件名和版本号不能代替身份。
  • 代码签名证明代码与签名身份的绑定和完整性校验条件,不证明业务逻辑安全、加固效果、隐私合规或服务端授权已经成立。
  • App Attest 属于运行期服务端验证边界,不能替代发布签名;发布签名有效也不能省略 challenge、断言验证、重放控制和风险处置。
  • 隐私清单与第三方 SDK 责任必须在加固前确定,因为依赖、资源和声明的变化可能改变审核材料,签名通过不会自动修正这些声明。
  • 交付前应运行可重复的清单校验,检查候选摘要、标识符、渠道、责任人和必要材料是否齐全;校验通过只是进入签名和分发流程的条件。

先把签名责任拆成可验收的角色,而不是一句“由客户签名”

加固接入会跨过研发、移动安全、证书管理和发布运营四类工作。若需求单只写“签名由客户负责”,发生问题时无法判断是签名身份选错、权限配置漂移、加固后候选被替换,还是上传人员拿了另一份 IPA。可执行的做法是给每个动作指定角色、输入、输出、批准条件和回执,并在工作开始前由双方确认。

责任矩阵至少要区分签名身份所有者、签名执行者、加固候选批准者、最终候选批准者、分发上传者、隐私声明维护者、第三方 SDK 负责人和平台证明服务端负责人。同一人可以承担多个角色,但每个角色仍要有独立动作记录。这样做不是增加流程,而是让失败能够落到一个具体输入和一个可修复责任点。

边界确认还要写明加固服务能够接触什么。通常可以提交候选文件、公开构建元数据和经过最小化处理的诊断材料;私钥、证书口令、Apple 账号会话、一次性验证码和无关业务数据不应因为“方便签名”进入普通工单或内容系统。若最终签名必须由资产所有者控制,就应把签名动作留在其受控环境,只交付摘要和结果回执。

iOS 加固签名与分发责任矩阵
动作责任角色必要输入可验收输出拒绝条件
批准加固前候选候选批准者文件摘要与构建标识批准记录摘要缺失或文件已变化
执行最终签名签名执行者加固后未签名候选与授权身份已签名候选摘要身份、渠道或权限不匹配
批准最终候选发布负责人签名检查与回归回执候选批准记录检查对象不是同一摘要
上传分发平台分发上传者已批准的最终候选平台接收回执上传文件摘要无法对应
维护隐私与 SDK 声明隐私和依赖负责人依赖清单与数据行为说明复核记录声明来源不清或版本未锁定

用三个候选身份固定加固前、加固后和最终签名阶段

一个可审计交付至少存在三个候选:进入加固流程的输入候选、加固完成但尚未执行最终发布签名的输出候选,以及准备上传的最终已签名候选。签名和重新打包都会改变文件字节,因此三个对象不能共享同一个 SHA-256。清单应记录每个对象的摘要、大小、生成时间、Bundle ID、版本、构建号和产生该对象的责任步骤。

Bundle ID、版本和构建号用于说明业务身份,却不足以唯一定位文件。同一组标识可以对应多次构建、不同编译器输入或不同签名结果;相同文件名也可能被复制覆盖。验收时应以实际文件重新计算的 SHA-256 为主键,再把 Team ID、计划分发渠道、签名证书公开指纹、权限清单摘要和来源证明作为关联属性,避免凭截图或文件名放行。

签名材料的记录要遵守最小披露。清单可保存证书公开信息、签名身份标签、Team ID、权限摘要和签名动作回执,不应保存私钥原文、导出密码或账号凭据。若签名通过硬件密钥、受控构建节点或专用签名服务完成,应记录执行环境标识、审批号与结果摘要,而不是把密钥复制到加固服务或发布人员电脑。

三个候选阶段的身份与批准证据
候选阶段身份主键关联字段谁批准不可替代证据
加固前输入输入文件 SHA-256Bundle ID、版本、构建号研发与安全实际提交文件的重算摘要
加固后未签名输出输出文件 SHA-256工具版本、配置摘要、输入摘要安全与发布输入到输出的转换回执
最终已签名候选最终文件 SHA-256Team ID、渠道、权限摘要发布负责人签名检查和同摘要回归
平台接收对象上传文件 SHA-256平台记录号与时间分发上传者上传前后的身份对应记录

正确理解 Apple 代码签名能证明什么,以及不能证明什么

Apple app code signing process 说明,平台使用代码签名、证书和运行时验证建立 App 代码与获准开发者身份之间的关系。工程上应把签名验证作为候选身份和完整性门禁:签名执行前确认目标身份与渠道,执行后从最终候选读取签名信息,并把结果绑定到该文件摘要。任何重新打包、资源变更或再签名都产生新的验收对象。

权限声明是签名边界中的高风险字段。加固前应导出原候选所需的能力和权限,说明哪些必须保留、哪些由渠道或账号配置决定;加固后再从最终候选读取实际结果,逐项比较。仅凭安装成功不能证明权限完全一致,因为未覆盖的系统能力、推送、关联域或密钥链访问可能只在特定路径和账号状态下暴露问题。

有效签名不等于安全结论。它不能证明加固策略阻断了逆向分析,不能证明第三方 SDK 没有收集数据,也不能证明服务端接受的请求来自可信业务会话。文章中的签名清单只用于控制身份、责任和交付一致性;保护强度、兼容范围和攻击面变化仍需要针对同一最终候选的独立测试证据。

代码签名可以支持和不能支持的结论
检查对象签名边界内的结论签名边界外的结论需要补充的证据
证书与身份候选关联到指定签名身份身份持有人一定批准业务行为审批记录与账号治理
代码完整性签名后代码变化可被验证机制识别代码不存在业务漏洞代码审计和安全测试
权限声明最终候选携带实际签名权限所有运行场景兼容同摘要设备与业务回归
加固效果无直接结论不能由签名有效推导保护强度独立逆向与运行验证

把分发渠道写成签名输入,不要到上传前才决定

分发渠道不是发布团队最后填写的备注,而是签名责任的输入。项目应在加固前写明计划渠道、负责账号、最终签名发生位置、允许使用的签名身份、需要保留的能力,以及平台拒绝时由谁收集回执。渠道尚未确定时可以保留候选,但不能把该候选标记为可发布,更不能提前声称签名方案已验收。

交接时应采用单向清单:加固输出先由候选批准者确认摘要,再进入受控签名环境;签名执行者输出最终摘要和公开签名检查结果;发布负责人只接受清单中的最终摘要;上传者在发送前再次重算文件摘要。任何人若重新导出、压缩、嵌入资源或改变权限,都必须回到新的候选记录,不能沿用旧批准。

平台接收回执也要限制结论。上传成功只能说明平台在该时点接受了这个提交步骤,不能代替后续审核、设备运行、业务回归或加固强度验证。相反,平台拒绝信息应与最终候选摘要、渠道和签名回执一起保存,便于判断问题属于身份配置、权限声明、隐私材料还是二进制本身,而不是无目标地重复签名。

分发渠道确定后的签名交接控制
阶段必须冻结的输入允许动作变更后的处理
渠道确认渠道、账号责任、能力需求评审与批准更新责任矩阵
签名执行加固后候选摘要与签名身份在受控环境签名生成新最终摘要
发布批准最终摘要与检查回执同摘要回归和批准任何字节变化都撤销批准
平台上传已批准最终文件重算摘要后上传记录拒绝或接收回执

把 App Attest 放在服务端验证边界,不能拿它替代发布签名

Apple App Attest server validation 要求服务端验证证明并将其与请求处理绑定。接入说明应指定谁维护服务端验证逻辑、谁保管相关服务端配置、如何生成挑战、如何处理断言计数或重放风险,以及验证失败时采用拒绝、降级还是人工复核。把验证仅放在客户端会让信任决定处于可被本地修改的环境中。

App Attest 与发布签名解决不同问题。发布签名用于代码身份和平台执行链,App Attest 为服务端提供平台证明相关信号;二者都不能独立证明用户已经获得业务授权。加固可能改变客户端代码组织,但是否影响证明接入必须由同一最终候选进行注册、挑战、断言和服务端验证回归,不能从编译通过或签名有效推导。

证明数据也需要最小化处理。日志可以记录请求关联号、结果类别、服务端策略版本和经过控制的诊断字段,不应公开设备标识、完整令牌、私钥或可重放材料。平台证明仍是风险信号,业务系统要结合账号状态、会话、权限和异常处置作决定;校验器只能确认责任字段存在,不能模拟 Apple 服务或宣布真实性已经通过。

App Attest 服务端验证责任边界
控制点责任方应保存的证据不能推出的结论
挑战生成服务端负责人策略版本与请求关联号客户端绝对可信
证明验证服务端负责人验证结果类别与时间用户已获业务授权
失败处置风控与业务负责人拒绝或降级规则所有失败都是攻击
加固回归安全与服务端团队同摘要端到端回执签名有效即可免测

隐私清单和第三方 SDK 由应用方负责,加固不能替代声明核对

Apple privacy manifests 说明,App 与第三方 SDK 的数据收集和 required reason API 信息需要进入有效的隐私清单。加固接入前应由应用方提供当前依赖锁定结果、SDK 版本、隐私清单位置和声明负责人;加固完成后再核对资源是否保留、归档是否包含预期清单,以及最终提交材料是否对应同一候选。

Apple third-party SDK requirements 强调开发者仍对集成 SDK 的代码与数据行为负责,部分列明的 SDK 还涉及签名和隐私清单要求。签名责任矩阵因此要包含 SDK 所有者:谁批准版本、谁确认来源、谁处理供应商更新、谁验证 SDK 自带清单与签名材料。加固服务处理二进制不等于接管这些产品和合规判断。

隐私清单是声明与审核材料,不是运行时数据隔离。文件存在不能证明 SDK 实际行为与声明一致,也不能证明网络传输、存储和服务端使用符合业务政策。若加固流程改变依赖、资源或初始化路径,应重新做清单对比和行为回归;若没有变化,也要以摘要或构建来源记录证明核对对象,而不是沿用上一版本截图。

隐私清单与第三方 SDK 复核责任
材料所有者加固前检查加固后检查证据边界
App 隐私清单隐私负责人声明与版本基线最终归档中的实际文件不证明运行行为一致
SDK 清单SDK 负责人依赖版本与来源资源、签名和声明保留不代表 SDK 已安全评估
required reason API研发与隐私负责人调用与理由映射同候选复核不能由文件存在自动确认
数据行为产品与合规负责人收集目的和去向运行观测与复核不属于代码签名结论

用来源证明和 SSDF 记录责任链,但不要把记录格式当成真实保证

SLSA Provenance v1.1 提供把产物主体、构建者、构建类型、外部参数和依赖材料写入来源证明的结构。用于 iOS 加固交付时,主体应绑定实际候选摘要,参数可记录经过审查的配置摘要,材料可引用输入候选和工具链标识。证明若只写版本号而不写主体摘要,仍无法回答最终上传文件是否来自该流程。

NIST SP 800-218 SSDF 将安全开发、来源、验证、变更和供应链风险放进组织实践。责任矩阵应据此指定证据保留人、复核人和异常处置人,并明确多长时间内能够取回候选、签名回执、依赖清单和测试结果。SSDF 不定义某个加固产品的功能,也不会替代 Apple 平台规则或项目实测。

证明格式本身也不保证内容真实。签发者身份、生成环境、时间、输入和主体摘要都要接受独立校验。交付演练应随机选择一个最终候选,从平台回执反向找到摘要、签名检查、来源证明、加固输入、SDK 清单和责任批准;任何断链都应在下一次发布前修复,而不是等线上崩溃或审核拒绝后再补材料。

签名交付证据的绑定对象和失效信号
证据绑定对象复核问题失效信号
来源证明加固后或最终候选摘要主体、构建者和输入是否一致只记录版本而无摘要
签名回执最终候选摘要身份、Team ID 和权限是否符合批准回执对应另一文件
SDK 与隐私基线构建来源和最终归档版本、声明与资源是否对应依赖漂移或材料缺失
平台回执实际上传对象能否反查完整责任链无法确认上传文件身份

用只读清单校验器在提交前发现签名责任和候选错配

下面的 Python 示例读取归档根目录和 `ios-signing-boundary.json`。清单必须声明候选相对路径与 SHA-256、Bundle ID、Team ID、分发渠道、六类责任角色、隐私清单和 SDK 清单状态。校验器拒绝绝对路径、目录逃逸、符号链接、格式错误、占位责任人和候选摘要错配,并输出待签名检查表。

脚本只读文件,不调用 `codesign`,不访问钥匙串,也不接触 Apple 账号或私钥。它确认的是清单完整性和候选字节身份,不确认签名证书有效、provisioning 合法、App Attest 服务端验证成功、隐私声明真实或加固保护强度。真实发布仍需在受控签名环境对同一摘要执行平台检查与设备回归。

准备 iOS 加固申请时,可整理三个候选阶段的摘要计划、Bundle ID、Team ID、分发渠道、权限基线、责任矩阵、SDK 与隐私清单、App Attest 服务端责任和来源证明,再通过御盾中央平台提交申请。提交动作不应附带私钥、证书密码或账号会话;校验通过也不表示签名、审核或兼容结果已经确认。

  • 候选路径受归档根目录限制且不允许符号链接
  • 实际候选 SHA-256 与清单完全一致
  • Bundle ID、Team ID 和计划分发渠道格式明确
  • 七类责任角色均有非占位负责人
  • 隐私清单已经复核并有负责人
  • 第三方 SDK 清单已经锁定并有负责人
  • 校验结果不冒充签名、审核、兼容或加固效果结论
检查 iOS 加固候选、签名责任和分发输入
from pathlib import Path
import hashlib
import json
import os
import re
import sys

if len(sys.argv) != 3:
    raise SystemExit(2)
root = Path(sys.argv[1]).resolve()
manifest_path = Path(sys.argv[2]).resolve()
if not root.is_dir() or not manifest_path.is_file() or root not in manifest_path.parents:
    raise SystemExit(2)
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
if manifest.get("schema") != "ios-signing-boundary-v1":
    raise SystemExit(2)

def safe_file(relative_path):
    if not isinstance(relative_path, str) or not relative_path or os.path.isabs(relative_path):
        raise SystemExit(2)
    unresolved = root / relative_path
    resolved = unresolved.resolve()
    if root not in resolved.parents or unresolved.is_symlink() or not resolved.is_file():
        raise SystemExit(2)
    return resolved

def sha256(path):
    digest = hashlib.sha256()
    with path.open("rb") as stream:
        for chunk in iter(lambda: stream.read(1024 * 1024), b""):
            digest.update(chunk)
    return digest.hexdigest()

candidate = manifest.get("candidate")
if not isinstance(candidate, dict):
    raise SystemExit(2)
expected = str(candidate.get("sha256", ""))
if not re.fullmatch(r"[0-9a-f]{64}", expected):
    raise SystemExit(2)
candidate_path = safe_file(candidate.get("path"))
actual = sha256(candidate_path)
if actual != expected:
    raise SystemExit(3)
identifiers = manifest.get("identifiers")
if not isinstance(identifiers, dict):
    raise SystemExit(2)
bundle_id = str(identifiers.get("bundleId", ""))
team_id = str(identifiers.get("teamId", ""))
channel = str(identifiers.get("distributionChannel", ""))
if not re.fullmatch(r"[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+", bundle_id):
    raise SystemExit(2)
if not re.fullmatch(r"[A-Z0-9]{10}", team_id) or not re.fullmatch(r"[a-z0-9_-]{2,40}", channel):
    raise SystemExit(2)
required_roles = {"inputApprover", "signingIdentityOwner", "finalSigner", "releaseApprover", "privacyOwner", "sdkOwner", "attestationVerifier"}
responsibilities = manifest.get("responsibilities")
if not isinstance(responsibilities, dict) or set(responsibilities) != required_roles:
    raise SystemExit(2)
for role, owner in responsibilities.items():
    if not isinstance(owner, str) or not re.fullmatch(r"[A-Za-z0-9_.@-]{3,80}", owner) or owner.lower() in {"tbd", "unknown", "none"}:
        raise SystemExit(2)
materials = manifest.get("materials")
if not isinstance(materials, dict) or materials.get("privacyManifestReviewed") is not True or materials.get("sdkInventoryLocked") is not True:
    raise SystemExit(2)
report = {"status": "signing-boundary-complete", "candidateSha256": actual, "bundleId": bundle_id, "teamId": team_id, "distributionChannel": channel, "responsibilityCount": len(responsibilities), "boundary": "manifest validation does not prove signature validity, review acceptance, attestation truth, privacy behavior, compatibility, or hardening strength"}
print(json.dumps(report, ensure_ascii=False, indent=2))

事实依据与适用边界

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

本文判断事实或工程依据适用限制
Apple 平台使用代码签名、证书和运行验证建立 App 代码与获准开发者身份之间的关系。Apple app code signing process 描述了 Apple 平台的代码签名过程和验证角色,可支持把签名身份与最终候选绑定为交付门禁。该资料不证明应用逻辑安全、加固强度、隐私合规或同一候选已经通过设备回归,也不能套用为 Android 签名结论。
App Attest 相关证明应由服务端验证并与请求处理绑定,客户端不能独自作出最终信任决定。Apple App Attest server validation 给出服务端验证 App Attest 材料的官方路径,因此责任矩阵需要明确服务端验证所有者。平台证明是风险信号,不是绝对设备可信、真实用户身份或业务授权,也不替代发布代码签名。
App 与第三方 SDK 的数据收集和 required reason API 信息需要纳入有效隐私清单。Apple privacy manifests 说明了为 App 或第三方 SDK 添加隐私清单的要求,可支持在加固前后核对相关资源和声明。隐私清单是声明与审核材料,文件存在不证明实际运行时数据收集、传输、存储和使用与声明完全一致。
应用开发者仍对集成的第三方 SDK 代码和数据行为负责,部分 SDK 还受到额外签名与隐私材料要求约束。Apple third-party SDK requirements 列出开发者对第三方 SDK 的责任及相关要求,因此 SDK 所有者不能从签名责任中省略。官方列表不表示某个 SDK 已通过安全评估,也不覆盖未列明依赖、Android 依赖或项目特有的数据处理。
安全发布流程应保留来源、构建、验证、变更和供应链风险处置证据,并指定责任人。NIST SP 800-218 SSDF 提供组织级安全软件开发实践,可支持建立证据保留、复核和异常处置责任。SSDF 不定义某个 iOS 加固产品的能力、签名步骤或测试通过标准,具体结论仍需项目证据。
来源证明可以把产物主体与构建者、构建类型、外部参数和依赖材料关联。SLSA Provenance v1.1 定义了 provenance 的主体和构建相关字段,可用于把加固输入、输出及配置摘要组成可检查关系。provenance 记录的结构不自动保证陈述真实,也不能独立证明运行兼容、隐私行为、平台审核或加固保护强度。
加固前输入、加固后未签名输出和最终已签名文件必须分别计算摘要并独立批准。这是基于文件字节身份和变更控制的工程判断:重新打包或签名会形成新的文件对象,旧摘要不能继续代表新对象。摘要只能证明字节身份和错配,不能说明文件来源可信、签名有效、业务正确或攻击防护达到目标。
私钥、证书密码、Apple 账号会话和一次性验证码不应进入普通加固工单或内容系统。这是基于最小权限、职责分离和凭据暴露面的工程判断;交付清单只需记录公开证书信息、角色和动作回执。具体密钥托管方式取决于组织安全策略、签名基础设施和渠道要求,本文不指定唯一实现,也不接触真实凭据。

工程常见问题

iOS 加固前为什么不能只写“最终由客户签名”?

因为这句话没有说明签名身份由谁批准、动作在哪里执行、哪个加固后摘要进入签名、谁核对权限、谁批准最终文件以及谁上传。出现安装、权限或审核问题时无法定位责任。应把身份所有者、签名执行者、候选批准者和上传者分别记录。

应该把 iOS 证书和私钥交给加固服务吗?

不能把交付便利当作交付私钥的默认理由。若组织要求资产所有者控制签名,应在其受控环境完成最终签名,只向流程提供公开证书指纹、Team ID、权限基线、候选摘要和签名结果。任何真实凭据传递都应经过独立授权和安全渠道。

为什么输入候选、加固输出和最终签名文件要有三个摘要?

它们是三个字节不同的对象。加固会改变输出,最终签名或重新打包也会改变文件。分别记录摘要才能证明哪个输入产生哪个输出,以及测试、批准和上传究竟针对哪一个文件。只记 Bundle ID、版本或文件名无法排除候选替换。

签名验证通过是否表示 iOS 加固已经成功?

不是。签名验证支持身份与完整性门禁,不证明保护策略抵抗逆向、业务功能兼容、隐私声明准确或第三方 SDK 安全。加固效果和兼容结果必须针对同一最终摘要分别执行安全验证、设备回归和业务验收。

App Attest 能否替代 iOS 发布签名或业务登录鉴权?

不能。App Attest 为服务端提供平台证明相关信号,发布签名建立代码与开发者身份的执行链,业务鉴权决定账号和操作权限。三者目的不同。服务端仍要验证证明、控制重放,并结合账号、会话和业务策略处理风险。

向御盾提交 iOS 加固申请前应准备哪些非敏感材料?

准备输入候选摘要、Bundle ID、版本与构建号、Team ID、计划渠道、权限基线、签名责任矩阵、SDK 锁定清单、隐私清单复核状态、App Attest 服务端责任和来源证明计划,再通过御盾中央平台提交申请。不要把私钥、证书密码或账号会话放入普通申请材料。

想用自己的 App 验证?

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

继续阅读: 御盾 VMP 接入前准备清单