先看结论与判断条件
- 可追溯表示能说明产物由哪些来源、工具和参数构建;可重复表示固定条件下可再次执行;字节级可复现必须由独立构建的产物摘要相同证明。
- 版本名、versionCode、Git 分支或文件名相同都不能代表同一加固输入,只有最终交付文件摘要才能标识具体二进制。
- 依赖锁文件之外还要保存实际解析图、仓库来源和依赖摘要,动态版本、缓存替换或私有仓库漂移会破坏比较。
- build type、product flavor、source set、applicationId、资源、Manifest 和签名配置共同形成变体,同仓库的其他 variant 不能共用基线结论。
- R8 规则、资源缩减、优化开关和对应输出属于构建证据;R8 不是 VMP,开启优化不能证明任何抗动态分析能力。
- 加固交接包应包含原始产物、输入清单、provenance、证据 subject、签名边界和最小业务基线,不包含私钥或未脱敏凭据。
先统一可追溯、可重复和可复现的口径
团队常把“同一版本能再打出来”称为可复现,但这个说法可能只表示构建命令能运行。送加固需要更严格的三层证据:可追溯说明产物来源与参数;可重复说明在固定环境能再次执行;字节级可复现说明两个独立构建的最终交付文件摘要一致。三者层层增加要求,不能用前一层的成功代替后一层。
并非所有项目一开始都能做到字节级一致。压缩元数据、生成文件顺序、签名阶段、工具随机性和构建时间都可能造成差异。此时可以如实建立“可追溯但非字节一致”的基线,但必须指定唯一原始产物摘要,并把不确定来源列为阻断或残余风险。不能为了赶进度把版本号相同写成二进制相同。
加固验证依赖原始包与保护包对照。如果原始包每次构建都不同,出现兼容差异时无法判断来自业务源码、依赖、R8、签名还是加固处理。基线门禁的商业价值,是减少后续争议和重复排查,让供应商、研发和测试围绕同一输入文件、同一配置和同一业务断言讨论。
| 声明 | 最低证据 | 允许结论 | 禁止替代 |
|---|---|---|---|
| 来源可追溯 | 源码、依赖、工具、参数和产物 subject | 能说明记录中的构建关系 | 声称产物字节可复现 |
| 条件可重复 | 固定清单下第二次构建可完成 | 构建流程在当前条件可再次执行 | 忽略两次产物差异 |
| 字节级可复现 | 独立工作区输入一致且产物 SHA-256 相同 | 两次交付文件字节一致 | 只比较版本号或安装行为 |
| 功能等价 | 两候选在定义用例中行为相同 | 当前业务观察等价 | 声称二进制相同 |
| 签名一致 | 签名方案与证书身份符合预期 | 发布身份在范围内一致 | 声称未签内容完全相同 |
| 加固输入基线 | 唯一文件摘要、输入清单与签名边界 | 加固任务指向明确对象 | 用文件名代替摘要 |
源码和依赖必须落到不可变解析结果
源码基线应记录 Git 提交摘要、子模块提交、LFS 或其他外部源码对象、生成代码输入和本地补丁状态。分支名、标签和压缩包名称都可能被移动或替换,不能单独作为来源身份。构建开始前应确认工作区无未记录修改,并把真正参与编译的源集与生成器版本写入清单。
依赖基线不能止于声明文件。Gradle 解析会受到直接与传递依赖、仓库顺序、版本约束、平台依赖和缓存内容影响,因此要保存锁文件摘要、实际解析坐标、来源仓库和可用的制品摘要。动态版本或可变模块如果无法冻结,应在门禁中明确阻断,而不是假设同一天解析结果永远相同。
私有依赖还要考虑可恢复性。企业应保留合法访问方式、制品身份和依赖证明,但不能把仓库令牌、用户名或内部地址写进公开交接清单。构建证明可以保存材料摘要和受控引用,密钥由独立凭据系统管理。若离开某台开发机就无法解析依赖,当前构建还不具备可重复基线。
- 源码使用不可变提交而非可移动分支名标识
- 子模块、外部源码、生成输入和本地补丁均入清单
- 工作区状态在构建前确认无未记录变化
- 锁文件与实际解析依赖图同时保存摘要
- 依赖来源和制品身份可恢复但不暴露凭据
- 动态版本与不可恢复私有依赖直接进入阻断项
工具链、变体和环境参数需要显式固定
Android 构建工具链至少涉及 JDK、Gradle、Android Gradle Plugin、Kotlin、Build Tools、平台 SDK,Native 项目还涉及 NDK、CMake 和编译器。清单应记录可验证版本或镜像摘要,而不是“使用当前稳定版”。工具升级可能改变字节码、资源处理、Manifest 合并和 Native 输出,即使源码没有变化也会产生新基线。
Android build variants 说明 build type、product flavor、source set、applicationId 和签名配置共同形成发布变体。送加固时要固定准确 variant,并保存最终 Manifest、资源选择、BuildConfig 或等价生成配置。debug 和 release、国内与海外 flavor、免费与付费 source set 可能使用不同 SDK、资源、证书和业务路径,不能共用一个原始包结论。
环境参数应只收集影响构建的部分,例如容器或系统镜像、架构、locale、时区、文件系统大小写行为和受控环境变量名称。不要把整个环境或秘密值倾倒进证明。更可靠的做法是提供最小允许参数列表,未声明变量进入构建时阻断;这样既减少漂移,也降低凭据泄露风险。
| 维度 | 应固定内容 | 比较方式 | 漂移后处理 |
|---|---|---|---|
| 源码 | 提交、子模块和生成输入 | 不可变摘要 | 重建基线 |
| 依赖 | 锁文件、解析图、来源和制品摘要 | 规范化排序后摘要 | 重新审批依赖 |
| 工具链 | JDK、Gradle、插件、SDK、NDK 和镜像 | 版本与镜像摘要 | 全量重建并对比 |
| 变体 | build type、flavor、source set 和 applicationId | 最终有效配置 | 禁止复用其他 variant 回执 |
| 环境 | 架构、locale、时区和允许变量 | 白名单与值摘要 | 解释或消除环境差异 |
| 产物 | 文件类型、大小、SHA-256 与 subject | 字节摘要 | 新建候选身份 |
R8、资源缩减和签名边界必须分开记录
Enable app optimization with R8 说明 R8 负责代码和资源缩减、优化与名称混淆。发布基线应保存 minify 和 shrinkResources 状态、所有规则文件摘要、消费者规则、映射、usage、seeds 或对应输出,以及被采用的工具版本。规则顺序、第三方 consumer rules 和缺失 keep 约束都可能改变最终二进制与运行语义。
R8 优化与软件加固不是同一个控制层。R8 的编译优化和名称混淆不等于 VMP,也不证明抗动态分析能力;反过来,加固工具也不能替代 R8 规则对反射、序列化和生成代码的保留责任。送加固前先把 R8 后原始 release 包稳定下来,后续差异才可以归到明确处理阶段。
签名边界要在交接前写清。可能交付未签名 APK、企业已签 APK、AAB 或渠道签名前产物,不同选择影响可比性和密钥责任。Sign your Android app 区分应用签名密钥、上传密钥、证书与 Play App Signing。清单只保存签名模式和证书摘要,不包含私钥;若签名阶段改变,两次产物摘要差异不能直接归为构建不稳定。
- R8 开关、规则、消费者规则和工具版本均有摘要
- mapping、usage、seeds 或对应输出绑定原始候选
- R8 优化与 VMP 控制在报告中明确区分
- 反射、序列化和生成代码 keep 责任由原项目维护
- 交付物是未签、已签、AAB 或渠道前产物已声明
- 签名清单只有模式和证书摘要,不包含私钥
两次干净构建要逐层比较而非只看能安装
基线验证应在两个独立干净工作区执行,清除项目输出并重新获取受控依赖,分别生成输入清单、构建日志和候选文件。两个工作区可以使用相同受控镜像,但不应复用第一个工作区的产物。若第二次只是复制缓存中的 APK,摘要相同也不能证明构建流程可重复。
比较顺序应从输入到输出:先比较源码、依赖、工具链、变体、R8 规则和签名边界,再比较产物摘要。输入不同而产物偶然相同,说明证明不完整;输入相同而产物不同,则需要定位非确定性来源。只有输入清单一致、构建确实独立且交付文件摘要相同,才写字节级可复现。
产物不同可继续做公开安全的结构对比,例如文件列表、每个条目摘要、Manifest、DEX、资源、Native 库、压缩元数据和签名块,但这些差异只用于定位。先确认是否生成时间、文件顺序、签名参数、生成代码或工具随机性,再修正构建。不能简单删除不同字段后宣布完全一致;规范化比较与最终文件摘要应同时保留。
| 输入清单 | 产物摘要 | 当前状态 | 允许动作 |
|---|---|---|---|
| 相同 | 相同 | 字节级重复证据成立 | 冻结交接包并继续业务基线 |
| 相同 | 不同 | 输出不可复现 | 定位生成、压缩、签名和工具非确定性 |
| 不同 | 相同 | 输入证明漂移 | 修正清单与构建路径,不直接放行 |
| 不同 | 不同 | 基线不可比较 | 重新冻结输入并独立构建 |
| 清单缺失 | 相同 | 只有产物巧合一致 | 补建来源、参数和材料证明 |
| 相同 | 未计算 | 产物身份缺失 | 计算最终交付文件摘要后再交接 |
用 provenance 和有类型声明绑定交接证据
SLSA Provenance v1.1 要求把产物 subject 与 builder、buildType、外部参数和依赖材料关联。对加固前基线,subject 是唯一原始交付文件摘要,外部参数包括变体、优化和签名边界,材料包括源码、依赖和工具镜像。它让供应商收到的文件可以回到具体构建,但不证明运行时安全。
in-toto Attestation Statement v1 用 subject、predicateType 和 predicate 组织声明。企业可以分别绑定构建证明、输入清单、R8 输出、业务基线和交接回执,并要求每份 statement 的 subject 与原始产物一致。声明格式不保证内容真实,仍要验证签发者、签名与门禁,不能把路径相似的旧声明复制给新候选。
NIST SP 800-218 SSDF 强调来源、构建、验证和变更证据。交接包应包含原始产物、manifest.json、provenance、声明索引、最小业务启动回执、已知差异和签名边界,由企业保留主副本。供应商只接收完成任务所需材料,不应获取源码仓库长期凭据、签名私钥或无关客户数据。
- provenance subject 等于最终原始交付文件摘要
- builder、buildType、参数与材料字段可复核
- 输入清单、R8 输出和业务回执分别有声明索引
- 每份声明验证签发者、签名和当前 subject
- 企业保留交接包主副本和访问审计
- 供应商不获得仓库长期凭据或签名私钥
用只读脚本规范化并比较两次构建清单
下面的 Python 脚本读取包含 build_a 和 build_b 的脱敏 JSON,检查源码、依赖、工具链、变体、优化、环境与签名边界字段,要求 provenance_subject_sha256 等于各自产物摘要。它把字典键和列表排序后比较,输出 input_differences 与 artifact_equal,不读取 APK 内部内容,也不修改构建目录。
当输入清单相同且产物摘要相同,输出 BYTE_IDENTICAL;输入相同而产物不同,输出 NON_REPRODUCIBLE_OUTPUT;任何输入差异则输出 INPUT_DRIFT。脚本拒绝缺失文件、缺失字段、错误摘要和 provenance subject 错配。它不会把 versionName、versionCode 或安装成功当作产物身份。
代码只验证调用方提供的清单,无法证明两次构建真的来自独立工作区,也无法确认清单没有遗漏影响输出的变量。生产门禁还应保存 CI 运行身份、日志和制品,复核依赖来源,并由构建 owner 签署。脚本输出是送加固前的结构证据,不是运行时安全或保护效果证明。
import json
import sys
from pathlib import Path
REQUIRED = {
"source",
"dependencies",
"toolchain",
"variant",
"optimization",
"environment",
"signing_boundary",
"artifact_sha256",
"provenance_subject_sha256",
}
INPUT_FIELDS = sorted(REQUIRED.difference({"artifact_sha256", "provenance_subject_sha256"}))
def stop(message):
raise SystemExit(f"invalid build manifests: {message}")
def normalize(value):
if isinstance(value, dict):
return {key: normalize(value[key]) for key in sorted(value)}
if isinstance(value, list):
normalized = [normalize(item) for item in value]
return sorted(normalized, key=lambda item: json.dumps(item, ensure_ascii=False, sort_keys=True))
return value
def require_build(value, name):
if not isinstance(value, dict):
stop(f"{name} must be an object")
missing = sorted(REQUIRED.difference(value))
if missing:
stop(f"{name} missing {missing}")
for field in ("artifact_sha256", "provenance_subject_sha256"):
digest = value[field]
if not isinstance(digest, str) or len(digest) != 64:
stop(f"{name}.{field} is not a SHA-256 string")
if value["artifact_sha256"] != value["provenance_subject_sha256"]:
stop(f"{name} provenance subject mismatch")
return value
if len(sys.argv) != 2:
raise SystemExit("usage: python build_manifest_diff.py MANIFESTS_JSON")
manifest_path = Path(sys.argv[1]).resolve()
if not manifest_path.is_file():
stop(f"file not found: {manifest_path}")
payload = json.loads(manifest_path.read_text(encoding="utf-8"))
build_a = require_build(payload.get("build_a"), "build_a")
build_b = require_build(payload.get("build_b"), "build_b")
differences = []
for field in INPUT_FIELDS:
left = normalize(build_a[field])
right = normalize(build_b[field])
if left != right:
differences.append({"field": field, "build_a": left, "build_b": right})
artifact_equal = build_a["artifact_sha256"] == build_b["artifact_sha256"]
if differences:
gate = "INPUT_DRIFT"
elif not artifact_equal:
gate = "NON_REPRODUCIBLE_OUTPUT"
else:
gate = "BYTE_IDENTICAL"
output = {
"input_differences": differences,
"artifact_equal": artifact_equal,
"build_a_artifact": build_a["artifact_sha256"],
"build_b_artifact": build_b["artifact_sha256"],
"gate": gate,
}
print(json.dumps(output, ensure_ascii=False, indent=2, sort_keys=True))送加固门禁以唯一输入、业务基线和责任人关闭
送加固门禁至少包含输入清单无缺失、两次构建差异已解释、唯一原始产物摘要、provenance subject 匹配、签名边界明确、R8 输出可回查、最小安装与核心业务基线通过、交接权限受控。任何输入漂移、产物身份不明、私有依赖不可恢复或签名责任不清,都应在上传前阻断。
如果项目只能达到可追溯而非字节级可复现,报告要明确具体原因、唯一交付产物、剩余差异和批准人。加固供应商的所有输出和测试都必须引用这一个原始摘要,不能在处理途中换包。后续源码、依赖、配置或工具链变化会创建新基线,旧证据不得通过相同版本号继续继承。
需要建立真实项目的加固前构建基线时,可通过御盾中央平台提交脱敏源码摘要、依赖清单、工具链、变体、R8 规则摘要、签名边界和两次构建差异。先确认原始输入可比较,再生成保护候选和设备回归,避免把原始构建漂移误判成加固问题。
- 两次独立构建的输入清单和产物摘要均已保存
- 不可复现差异有原因、处理和批准人
- 唯一原始产物摘要进入每个加固任务和回执
- 原始包完成最小安装、启动和核心业务基线
- 签名、仓库和上传权限遵循最小授权
- 任何源码、依赖、工具或配置变化重建基线
事实依据与适用边界
以下内容区分官方事实、本文工程判断和不能外推的范围,避免把设计建议写成未经验证的产品结论。
| 本文判断 | 事实或工程依据 | 适用限制 |
|---|---|---|
| 构建证明应绑定产物主体、构建者、构建类型、外部参数和依赖材料。 | SLSA Provenance v1.1 定义 subject、builder、buildType、externalParameters 和 resolvedDependencies 等字段。 | provenance 只能证明记录的构建关系,不能单独证明运行时安全、字节级可复现或加固效果。 |
| 供应链声明应把产物摘要与有类型的声明负载绑定。 | in-toto Attestation Statement v1 使用 subject 摘要、predicateType 和 predicate 组织证明。 | 声明格式不保证签发者可信或内容真实,仍需验证签名、subject 和构建门禁。 |
| 安全发布应保留来源、构建、验证和变更证据。 | NIST SP 800-218 SSDF 把来源追溯、构建完整性、验证、维护和供应链实践纳入安全开发。 | SSDF 是组织级框架,不定义 App 加固功能,也不保证具体 Android 构建可复现。 |
| build type、product flavor、source set、applicationId 和签名配置会共同形成 Android 构建变体。 | Android build variants 说明构建类型、产品风味、源集、应用身份与签名配置的 variant 组合。 | 同一代码仓库不代表所有 variant 具有相同资源、SDK、证书或运行行为。 |
| R8 的职责包括代码和资源缩减、优化与名称混淆,发布构建应保留规则和输出。 | Enable app optimization with R8 说明 R8 优化、缩减、混淆和相关配置输出。 | R8 编译优化不等于 VMP,也不证明抗动态分析能力或加固后兼容通过。 |
| Android 发布需要区分应用签名密钥、上传密钥、证书和 Play App Signing 责任。 | Sign your Android app 说明应用签名、上传密钥、证书与 Play App Signing 的关系。 | 文档不能确认实际候选使用了正确生产证书,签名身份也不证明未签内容可复现。 |
| 只有独立构建输入相同且最终产物摘要相同,才能声明当前文件字节级可复现。 | 工程判断:版本名、安装行为或规范化结构相同都不能替代最终交付文件的字节摘要比较。 | 脚本与清单不能自动证明工作区真正独立或所有影响变量都已记录,仍需 CI 回执和人工复核。 |
| 送加固任务应绑定唯一原始产物摘要与签名边界。 | 工程判断:加固输入不唯一时,保护结果、兼容差异和供应商回执都无法回到明确原始对象。 | 没有项目原始包与真实回执时,不能声明任何候选已完成可复现、业务基线或加固验证。 |
工程常见问题
同一个 versionName 和 versionCode 能否说明是同一加固输入?
不能。版本字段可以相同但字节不同,应以最终交付文件 SHA-256、构建输入清单和签名边界标识具体对象。
两次构建都能安装,是否算可复现?
只能说明当前功能观察接近。字节级可复现还要求独立构建输入一致且最终产物摘要相同,并保留构建证明。
为什么送加固前还要保存 R8 规则和 mapping?
R8 会改变代码、资源和名称。规则与输出可解释原始 release 包结构,并帮助区分原始优化问题和后续加固差异。
签名后的 APK 摘要不同,是否一定说明构建不稳定?
不一定。先比较签名前产物和签名边界,签名参数或证书变化会改变最终文件,应单独记录并按阶段判断。
provenance 已生成,是否自动说明构建可复现?
不能。provenance 记录构建关系;可复现仍需第二次独立构建、输入比较和最终产物摘要证据。
建立加固前基线最少需要哪些材料?
准备源码与依赖摘要、工具链、variant、R8 规则、签名边界、两次构建清单、产物摘要、provenance 和业务基线回执。