先看结论与判断条件

  • 文件名、版本名、流水线编号和对象存储地址都不是产物身份,交接门禁必须从接收端重算的 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 与证书输出检查。若输出证书不在该渠道和阶段的允许集合,或签名后文件又发生修改,发布任务应拒绝接收。证书匹配仍不说明密钥托管过程安全,也不能证明该账号有业务发布授权。

Android 签名身份的阶段化解释
对象常见签名身份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实际产物解析满足渠道版本策略不自动改写
artifactKindAPK 或 AAB 输出选择相应签名与检查拒绝错误工具链
configDigest规范化构建或加固配置绑定转换声明变化建立新审批
subjectSha256接收文件重算作为所有证据主键不一致立即停线

用只读脚本检查阶段连续性和允许证书

下面的 Python 示例读取一份交接清单,要求阶段顺序固定为 build、upload、harden、sign 和 publish。每条记录包含 inputSha256、outputSha256、mutatesBytes、receiptId 与 certificateSha256。脚本检查上一阶段输出是否成为下一阶段输入,非变更阶段前后摘要是否相同,变更阶段是否产生新摘要。

签名阶段的证书摘要必须出现在清单顶层 allowedCertificateSha256s 中,发布阶段必须沿用签名阶段证书。所有摘要都使用严格的六十四位十六进制格式,阶段缺失、重复或断链会返回非零状态。脚本不读取 APK、不调用签名服务、不接触密钥,也不会修改任何输入;生产门禁仍应对实际文件重算摘要并验证声明签名。

这个校验器解决的是 manifest 内部一致性,适合作为接收端的第一道失败门禁。它不能确认 manifest 的观察值真实,也不验证 apksigner 输出、Statement 签名或渠道回执。实际流程应先生成不可由上游覆盖的观察记录,再把记录交给该类策略校验,否则攻击者或误配置仍可能同时替换文件和自述摘要。

  • 阶段顺序固定且没有缺失或重复
  • 每个接收摘要等于前一阶段输出
  • 非变更阶段保持前后摘要相同
  • 加固和签名产生新 subject 并保留 parent
  • 签名证书属于渠道与阶段允许集合
  • 发布阶段沿用已验证签名身份
  • 脚本结果不冒充实际文件和声明验证
校验五阶段 subject 摘要连续性与签名证书允许列表
#!/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 接入前应准备哪些信息?

准备构建变体、各阶段输入输出、摘要重算点、证明格式、证书公钥摘要、渠道签名模式、失败处置和回退责任,再通过御盾中央平台提交申请。

想用自己的 App 验证?

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

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