先看结论与判断条件
- 文件名、版本名、流水线编号和对象存储地址都不是产物身份,交接门禁必须从接收端重算的 SHA-256 开始。
- 构建、加固和签名通常会产生新字节身份;上传、审批和发布转交若声明不改字节,前后摘要必须完全一致。
- 每个阶段要同时保存输入 subject、输出 subject、动作类型、证明引用、执行身份、策略版本和接收回执,避免报告与候选错配。
- 上传密钥、应用签名密钥、证书和 Play App Signing 属于不同责任,证书允许列表必须按渠道与签名阶段解释。
- in-toto Statement 与 SLSA provenance 可以绑定产物和构建事实,但格式正确不等于声明真实,仍需验证签名者、策略与实际文件。
- 流水线通过只证明记录的交接链满足身份策略,不能外推应用兼容、防护强度、商店审核、线上发布或运行时安全。
固定交付身份的起点是接收端重算摘要
软件加固加入 CI 以后,原来的一次构建会变成多次文件交接:构建系统输出原始候选,上传服务接收文件,加固任务生成新文件,签名服务再次改变字节,发布任务把最终对象送到渠道。任何一个环节如果只传文件名、versionName 或 jobId,下游都可能拿到同名但不同字节的对象,测试报告也可能被错误复用。
交接的第一条规则是接收方对实际收到的文件重新计算 SHA-256,并把结果写成 inputSubject。发送方提供的摘要只能用于比对,不能替代接收端观察。文件大小、修改时间、URL、构建目录和对象存储 key 可以帮助定位,但都不是内容身份;它们变化不一定表示字节变化,相同也不保证内容相同。
第二条规则是结论只跟随摘要,不跟随文件名。测试、扫描、加固配置、签名检查和发布审批都要引用精确 subject。若文件被重新压缩、对齐、写入资源、签名或重新构建,即使版本号不变,也必须建立新 subject,并通过 lineage 指向上一个对象,不能把旧报告直接贴到新文件上。
| 标识 | 可用于什么 | 为什么不足 | 门禁处理 |
|---|---|---|---|
| 文件名 | 帮助人员识别用途 | 可复制、重命名和覆盖 | 不作为身份键 |
| 版本名 | 表达产品版本 | 相同版本可多次构建 | 只作元数据 |
| 流水线编号 | 定位一次执行 | 执行可能产生多个文件 | 关联但不替代摘要 |
| 对象地址 | 定位存储位置 | 内容可替换或派生 | 下载后重算摘要 |
| 文件 SHA-256 | 锁定观察到的字节 | 不解释来源和安全性 | 作为 subject 主键 |
| 签名证书摘要 | 说明签名身份线索 | 不证明私钥和渠道流程安全 | 与阶段和渠道联合判断 |
把五个阶段写成显式交接,而不是一条模糊流水线
构建阶段应记录源版本、构建变体、构建类型、外部参数、依赖材料和输出摘要;上传阶段记录接收摘要与存储回执;加固阶段记录输入摘要、配置摘要和加固输出摘要;签名阶段记录待签名摘要、签名后摘要和证书;发布阶段记录接收的最终摘要、目标渠道和渠道回执。每一步都能回答“收到了什么”和“交出了什么”。
SLSA Provenance v1.1 将 artifact subject、builder、build type、external parameters 和 resolved dependencies 等信息放进构建证明。这个结构适合描述构建或加固任务怎样产生输出,但 provenance 只覆盖其声明的过程。它不能自动证明输入文件真的来自批准分支,也不能证明加固结果兼容、抗攻击或已经进入商店。
NIST SP 800-218 SSDF 从组织实践角度要求把来源、构建、验证、变更与供应链风险纳入安全开发和发布流程。落到交接门禁,关键不是增加一份大报告,而是让每个系统只接受可验证的前序输出,并把失败留在原阶段处理。若责任边界不清,摘要再完整也会出现无人判断的异常。
| 阶段 | 接收对象 | 正常输出 | 主要责任 |
|---|---|---|---|
| 构建 | 源与锁定依赖 | 原始候选 subject | 确定 variant 和构建事实 |
| 上传 | 原始候选文件 | 同摘要存储对象与回执 | 重算并确认未改字节 |
| 加固 | 已接收的原始 subject | 新的加固 subject | 绑定配置、输入和输出 |
| 签名 | 待签名加固 subject | 新的签名 subject 与证书 | 隔离密钥并核对允许身份 |
| 发布 | 最终签名 subject | 渠道回执或派生身份 | 确认上传对象与渠道语义 |
| 归档 | 交接链及相关证明 | 不可变索引和保留策略 | 支持复核与撤回 |
先声明哪些阶段允许改变字节,哪些阶段绝不允许
身份链不能简单要求所有摘要相同,因为加固和签名本来就可能改变文件。正确做法是给阶段声明 operation 与 mutatesBytes。构建产生第一个候选;加固以输入摘要为 parentSubject,输出一个不同的新摘要;APK 签名写入签名数据后再产生新摘要。每次合法变化都要有动作证明和责任主体。
上传、审批转交和发布前暂存如果声明只搬运文件,就不得改变字节。接收摘要与发送摘要不一致时,应停止后续任务,保留双方回执,并核对是否发生了错误解压、重新压缩、自动优化、换包或存储覆盖。不能由脚本把期望摘要自动更新成当前值,因为那会把异常变成新的“正确”基线。
Android apksigner 文档指出,APK 签名后如果再对文件进行修改,签名会失效。因此 zipalign 等需要改变 APK 的处理应处于适用签名步骤之前,签名之后的任何字节变更都必须视为新对象并重新验证。apksigner 验证成功只说明工具检查的签名事实,不证明 keystore 管理、发布授权或归档流程正确。
| 操作 | mutatesBytes | 摘要规则 | 异常处理 |
|---|---|---|---|
| 上传与下载 | false | 接收等于发送 | 不一致立即阻断 |
| 审批转交 | false | 审批 subject 等于交付 subject | 拒绝复用报告 |
| 加固转换 | true | 输出不同并引用输入 | 缺 lineage 不准签名 |
| APK 签名 | true | 签名后建立新 subject | 重新计算并验证证书 |
| 签名后暂存 | false | 所有副本摘要相同 | 发现变化重新走签名 |
| 渠道派生 | 按渠道定义 | 上传对象和派生对象分别记录 | 不把两者混为同一 APK |
用 Statement 把证明绑定到 subject,但还要验证声明者
in-toto Attestation Statement v1 使用 subject digest、predicateType 和 predicate 组织有类型声明。加固任务可以把输出文件摘要放在 subject,把输入摘要、配置摘要、工具身份和检查结果放入特定 predicate。测试任务也可以发布自己的声明并引用同一候选,使报告不能仅凭相同版本号复制给另一个文件。
Statement 是信封结构,不保证内容真实。门禁还要验证声明签名或可信身份、predicateType 是否在允许列表、subject 是否恰好匹配当前文件、predicate 中的策略版本是否被批准,以及签发时间和撤销状态是否符合组织规则。一份没有可信签名的 JSON,即使字段齐全,也只能作为待核对材料。
证明应按责任拆分。构建系统声明原始产物来源,加固执行方声明转换输入与输出,测试系统声明运行过的候选,签名系统声明签名后对象和证书观察,发布系统声明上传对象与渠道回执。不要让单一流水线账户代表所有角色,否则一处凭据失陷就可能同时伪造构建、验证与发布结论。
| 字段 | 检查内容 | 失败风险 | 边界 |
|---|---|---|---|
| subject.digest | 与当前实际文件摘要相同 | 证明引用错包 | 只锁定字节 |
| predicateType | 属于阶段允许类型 | 错误声明被当成审批 | 不证明 predicate 真实 |
| issuer | 来自批准执行身份 | 任意账户自我背书 | 还需撤销与时效策略 |
| parentSubject | 与上一阶段输出相同 | 转换 lineage 断裂 | 不代表运行兼容 |
| policyVersion | 等于当前批准规则 | 过期门禁继续生效 | 需独立策略管理 |
| statement digest | 声明文件本身可追溯 | 声明被替换 | 不替代声明签名验证 |
签名交接必须区分上传密钥、应用签名密钥和证书
Sign your Android app 区分应用签名密钥、上传密钥、证书和 Play App Signing 的责任。采用 Play App Signing 时,团队上传的 AAB 可以由上传密钥签名,用户设备最终收到的 APK 则由应用签名密钥签名。CI 不能用一个含糊的 expectedCertificate 字段同时代表上传对象和设备交付对象。
证书允许列表应按 channel、artifactKind 和 signingStage 建模。例如直接分发 APK 核对对应渠道应用签名证书,Play 上传 AAB 核对上传身份,设备侧抽样再记录派生 APK 的应用签名证书。允许列表只保存证书公钥摘要和策略元数据,不接触私钥、keystore 密码或服务账号令牌。
签名服务接收时重算待签名文件摘要,签名完成后重算输出摘要,并运行 apksigner verify 与证书输出检查。若输出证书不在该渠道和阶段的允许集合,或签名后文件又发生修改,发布任务应拒绝接收。证书匹配仍不说明密钥托管过程安全,也不能证明该账号有业务发布授权。
| 对象 | 常见签名身份 | CI 检查 | 不能混淆 |
|---|---|---|---|
| 直接分发 APK | 渠道应用签名证书 | 文件摘要、签名方案与证书 | 调试或上传证书 |
| Play 上传 AAB | 上传密钥证书 | 上传候选和轨道回执 | 设备最终应用证书 |
| Play 派生 APK | 应用签名密钥证书 | 受控安装样本或派生回执 | 本地重签 APK |
| 内部测试包 | 受控测试证书 | 环境与用途限制 | 商业发布候选 |
| 证书摘要 | 公开身份指纹 | 对照阶段允许列表 | 私钥或发布权限 |
| 签名后文件 | 新的字节 subject | 再次计算文件 SHA-256 | 沿用签名前摘要 |
变体身份要在进入加固前确定,并贯穿所有回执
Android build variants 文档说明 build type、product flavor、source set、applicationId 与签名配置会组合成不同变体。同一提交可以同时产生多个 applicationId、资源、SDK 集和签名设置不同的文件。CI 如果只传 git commit 和 versionName,就无法区分哪个变体进入了加固、哪个变体获得了测试报告。
交接记录应包含 variantId、applicationId、versionCode、artifactKind 和构建配置摘要,但这些字段是 subject 的解释信息,不替代文件摘要。下游可以先按 subject 锁定字节,再验证元数据是否符合目标渠道。若解析值与上游声明不一致,应保留观察值并阻断,不能修改 manifest 去迎合当前文件。
构建与签名材料的责任边界可参考[软件加固交付前构建与签名材料说明](/zh-cn/articles/hardening-build-signing-materials/)。该页面处理哪些材料可提交以及私钥怎样隔离;本篇只处理 CI 各阶段如何传递产物身份。即使材料准备完整,也必须为每个实际变体建立独立交接链。
| 字段 | 来源 | 接收端复核 | 不一致处理 |
|---|---|---|---|
| variantId | 构建计划 | 与批准任务对应 | 阻断错误渠道 |
| applicationId | 实际产物解析 | 与目标发布位匹配 | 不按文件名猜测 |
| versionCode | 实际产物解析 | 满足渠道版本策略 | 不自动改写 |
| artifactKind | APK 或 AAB 输出 | 选择相应签名与检查 | 拒绝错误工具链 |
| configDigest | 规范化构建或加固配置 | 绑定转换声明 | 变化建立新审批 |
| subjectSha256 | 接收文件重算 | 作为所有证据主键 | 不一致立即停线 |
用只读脚本检查阶段连续性和允许证书
下面的 Python 示例读取一份交接清单,要求阶段顺序固定为 build、upload、harden、sign 和 publish。每条记录包含 inputSha256、outputSha256、mutatesBytes、receiptId 与 certificateSha256。脚本检查上一阶段输出是否成为下一阶段输入,非变更阶段前后摘要是否相同,变更阶段是否产生新摘要。
签名阶段的证书摘要必须出现在清单顶层 allowedCertificateSha256s 中,发布阶段必须沿用签名阶段证书。所有摘要都使用严格的六十四位十六进制格式,阶段缺失、重复或断链会返回非零状态。脚本不读取 APK、不调用签名服务、不接触密钥,也不会修改任何输入;生产门禁仍应对实际文件重算摘要并验证声明签名。
这个校验器解决的是 manifest 内部一致性,适合作为接收端的第一道失败门禁。它不能确认 manifest 的观察值真实,也不验证 apksigner 输出、Statement 签名或渠道回执。实际流程应先生成不可由上游覆盖的观察记录,再把记录交给该类策略校验,否则攻击者或误配置仍可能同时替换文件和自述摘要。
- 阶段顺序固定且没有缺失或重复
- 每个接收摘要等于前一阶段输出
- 非变更阶段保持前后摘要相同
- 加固和签名产生新 subject 并保留 parent
- 签名证书属于渠道与阶段允许集合
- 发布阶段沿用已验证签名身份
- 脚本结果不冒充实际文件和声明验证
#!/usr/bin/env python3
import json
import re
import sys
from pathlib import Path
SHA256 = re.compile(r"^[0-9a-f]{64}$")
EXPECTED = ["build", "upload", "harden", "sign", "publish"]
MUTATING = {"build", "harden", "sign"}
def digest(value, label, allow_empty=False):
if allow_empty and value is None:
return None
normalized = str(value).lower()
if SHA256.fullmatch(normalized) is None:
raise ValueError(f"invalid {label}")
return normalized
def load_handoff(path):
data = json.loads(Path(path).read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise ValueError("handoff must be an object")
return data
def validate(data):
allowed = data.get("allowedCertificateSha256s")
if not isinstance(allowed, list) or not allowed:
raise ValueError("certificate allowlist is required")
allowed_set = {digest(value, "allowed certificate") for value in allowed}
stages = data.get("stages")
if not isinstance(stages, list) or [item.get("stage") for item in stages if isinstance(item, dict)] != EXPECTED:
raise ValueError("stages must follow the approved sequence")
previous = None
signing_certificate = None
for item in stages:
stage = item["stage"]
input_sha = digest(item.get("inputSha256"), f"{stage} input", allow_empty=stage == "build")
output_sha = digest(item.get("outputSha256"), f"{stage} output")
receipt = item.get("receiptId")
if not isinstance(receipt, str) or not receipt.strip():
raise ValueError(f"missing receipt for {stage}")
if stage != "build" and input_sha != previous:
raise ValueError(f"subject chain breaks at {stage}")
observed_mutation = input_sha is None or input_sha != output_sha
if bool(item.get("mutatesBytes")) != (stage in MUTATING):
raise ValueError(f"mutation policy mismatch at {stage}")
if observed_mutation != (stage in MUTATING):
raise ValueError(f"digest transition mismatch at {stage}")
certificate = item.get("certificateSha256")
if stage == "sign":
signing_certificate = digest(certificate, "sign certificate")
if signing_certificate not in allowed_set:
raise ValueError("sign certificate is not allowed")
if stage == "publish" and digest(certificate, "publish certificate") != signing_certificate:
raise ValueError("publish certificate differs from sign stage")
previous = output_sha
return {"status": "handoff-chain-valid", "releaseSubjectSha256": previous, "stageCount": len(stages)}
if len(sys.argv) != 2:
raise SystemExit(2)
try:
result = validate(load_handoff(sys.argv[1]))
except (OSError, json.JSONDecodeError, ValueError) as error:
print(str(error), file=sys.stderr)
raise SystemExit(3)
print(json.dumps(result, ensure_ascii=False, indent=2))发布准入要保留断链证据,并明确结论能覆盖多远
发布任务只应接收一份冻结的 release manifest,其中列出最终 subject、变体、签名阶段、证书摘要、前序 Statement 引用、测试证据引用、审批策略版本和目标渠道。任务下载实际对象后再次计算摘要,验证签名与声明,再上传渠道。若渠道会生成派生 APK,应把上传对象和设备派生对象作为关联但不同的身份保存。
任何断链都应使候选进入 blocked,而不是由下游重新命名或重新签名后继续。记录应包含期望摘要、观察摘要、发生阶段、接收时间、执行身份和原始回执摘要,敏感地址与令牌留在受控系统。恢复时从最后一个已验证 subject 重新执行后续步骤,并使旧的审批和回执失效,防止两条候选链同时被误发布。
准备把软件加固接入 CI 时,可整理当前构建变体、原始与加固文件、阶段责任、证明格式、证书公钥摘要、渠道签名模式、接收端重算点、失败处置和回退流程,再通过御盾中央平台提交申请。最终方案和能力以项目评估与实际回执为准;身份链通过不代表兼容、防护强度、商店审核或线上发布已经完成。
| 门禁 | 必须看到的证据 | 通过后可说明 | 仍不能说明 |
|---|---|---|---|
| subject 连续 | 逐阶段输入输出摘要 | 交接链未出现记录内断裂 | 声明观察值真实 |
| 证明有效 | 可信签名、类型和策略 | 声明来自批准身份 | 运行时业务正确 |
| 签名符合 | apksigner 与允许证书 | 签名对象符合阶段策略 | 密钥托管绝对安全 |
| 变体匹配 | applicationId、版本和配置 | 对象适用于目标发布位 | 其他变体等价 |
| 渠道接收 | 上传回执或受控下载样本 | 该对象到达指定渠道步骤 | 已经公开或审核通过 |
| 运行验收 | 同摘要候选的设备断言 | 已测范围业务可执行 | 全部设备和攻击被覆盖 |
事实依据与适用边界
以下内容区分官方事实、本文工程判断和不能外推的范围,避免把设计建议写成未经验证的产品结论。
| 本文判断 | 事实或工程依据 | 适用限制 |
|---|---|---|
| 构建证明可以绑定产物 subject、构建者、构建类型、外部参数和依赖材料。 | SLSA Provenance v1.1 定义构建来源证明模型及其核心字段。 | provenance 只支持记录的构建事实,不能单独证明运行时安全、兼容或保护强度。 |
| 供应链声明可以用 subject digest 把产物与有类型的 predicate 绑定。 | in-toto Attestation Statement v1 定义 subject、predicateType 与 predicate 的声明结构。 | 声明格式不保证内容真实,仍需验证声明签名者、信任策略、时效和撤销状态。 |
| 安全开发与发布流程应保留来源、构建、验证和变更证据,并管理供应链风险。 | NIST SP 800-218 SSDF 给出组织级安全软件开发和发布实践。 | SSDF 不定义某个移动加固产品的具体功能,也不证明项目已执行相应实践。 |
| Android 发布责任需要区分应用签名密钥、上传密钥、证书和 Play App Signing。 | Sign your Android app 说明 Android 应用签名以及 Play App Signing 的相关身份和流程。 | 平台文档不能确认某个实际文件使用了正确生产证书,也不能证明私钥保管安全。 |
| apksigner 可以验证 APK 签名方案和证书,签名后对 APK 的修改会使签名失效。 | Android apksigner 文档说明签名、验证、证书输出以及签名后修改限制。 | 工具验证成功不证明渠道授权、keystore 管理、候选归档和发布对象全部正确。 |
| build type、product flavor、source set、applicationId 与签名配置会共同形成构建变体。 | Android build variants 文档说明 Android 构建变体的组合与配置。 | 同一仓库和提交不能证明不同 variant 具有相同资源、SDK、证书和运行行为。 |
| 不改变字节的交接阶段应保持前后摘要相同,改变字节的阶段应建立新 subject 并引用父身份。 | 工程判断:只有显式区分搬运和转换,才能同时发现静默替换并保留合法的加固、签名 lineage。 | 具体阶段和摘要算法策略应由组织威胁模型、工具链和渠道流程确认。 |
| 接收端应从实际文件重算摘要,不能只信任发送方自述或自动更新期望值。 | 工程判断:文件与自述摘要可能被同时替换,独立观察和失败保留才能形成有效交接门禁。 | 摘要只锁定字节,仍需可信声明、签名、访问控制、运行测试和渠道回执。 |
工程常见问题
用版本号和 CI jobId 能否固定加固交付产物?
不能。一个版本或任务可能产生多个文件,下游必须对实际接收文件重算 SHA-256,并把报告、证明和审批绑定到该 subject。
加固后摘要变化是否说明文件被篡改?
不一定。加固属于预期的字节转换,应生成新 subject 并引用输入摘要;没有批准动作证明或在搬运阶段发生变化才应视为断链。
签名前后的 APK 为什么不能沿用同一个摘要?
APK 签名会写入签名相关数据,签名后是新的字节对象。CI 应记录待签名摘要、签名后摘要、签名方案和允许证书。
有 SLSA provenance 是否就能证明加固包安全?
不能。它能描述记录的构建或转换来源,还要验证签名者和策略;运行兼容、业务正确与保护强度需要同一候选的其他证据。
Play 上传证书能否作为设备最终 APK 的证书允许值?
不能直接混用。上传密钥与应用签名密钥责任不同,应按上传 AAB、直接分发 APK 或 Play 派生 APK 分别解释并验证。
申请加固 CI 接入前应准备哪些信息?
准备构建变体、各阶段输入输出、摘要重算点、证明格式、证书公钥摘要、渠道签名模式、失败处置和回退责任,再通过御盾中央平台提交申请。