先看结论与判断条件
- 交接主键是待发布文件的 SHA-256,而不是文件名、网盘链接、构建任务号或聊天记录;所有报告与批准都必须引用同一摘要。
- 发布团队要区分应用签名密钥、上传密钥、证书和平台托管签名责任,交接清单只记录允许公开的证书摘要,不传递私钥或口令。
- 已经完成 APK 签名的文件不得再修改;任何 zipalign、资源替换、渠道注入或重打包动作都要回到既定签名顺序并生成新候选。
- 来源证明和声明应把产物主体摘要与构建者、构建类型、参数、材料及验收负载绑定,格式正确仍需核对签名者和内容可信度。
- 上传岗位必须从本地实际文件重新计算摘要、读取包版本、执行签名验证和提取证书,不能只对照交付方发来的截图或表格。
- 上传成功只是发布流程中的一个环节;最终记录还要绑定渠道、版本、时间、操作者、回执和回滚候选,且不得把错配门禁通过写成上架审核通过。
交接的目标是让同一个文件贯穿验收、签名和上传
软件加固完成后,研发、测试、安全、签名和发布岗位经常通过不同系统传递产物。文件可能被重命名、压缩、解压、再次签名或由另一个构建任务覆盖。真正危险的不是交接步骤多,而是各岗位只认文件名和版本号,导致测试通过的候选与最终上传文件不是同一个字节对象。
受控交接应建立 releaseCandidateId,并以 SHA-256 作为不可变主键,同时保存包名、versionCode、versionName、签名证书摘要、格式、目标渠道、构建来源和验收报告摘要。后续每份批准单、测试回执、来源声明与上传记录都引用该主键。文件名可以服务人类识别,但任何同名不同摘要都必须被视为新候选。
工程判断上,发布岗位的任务不是相信上一岗位,而是对手中的实际文件独立提取身份元组并与冻结清单比较。本文没有实际御盾候选、证书和渠道回执,因此只提供交接门禁方法,不声称任何具体包已经完成签名、验收或上架。
| 字段 | 从哪里取得 | 用于阻止什么 | 漂移处置 |
|---|---|---|---|
| artifactSha256 | 实际交付文件 | 同名替换与字节变化 | 立即停止 |
| packageName | APK Manifest | 上传错误应用 | 返回构建方 |
| versionCode/versionName | APK Manifest | 版本序列错乱 | 核对发布计划 |
| certificateSha256 | 签名验证输出 | 错误签名身份 | 禁止上传 |
| provenanceSubject | 来源证明 subject | 报告与产物错挂 | 重新生成或补证 |
签名责任必须区分应用密钥、上传密钥和证书
Sign your Android app 说明 Android 发布需要理解应用签名密钥、上传密钥、证书以及 Play App Signing 下的责任分工。交接清单应明确当前文件由谁签名、预期证书摘要是什么、渠道是否还会执行托管签名,以及上传岗位是否只持有上传授权。不能把“生产签名”当成一个没有主体的勾选项。
证书包含可用于识别签名者的公开信息,私钥则必须保持秘密。交接包可以记录证书 SHA-256、别名对应的责任角色和密钥托管系统回执,但不应携带 keystore、私钥、口令、临时令牌或恢复材料。发布人员只获得完成岗位职责所需的最小权限,密钥调用应由受控系统记录。
签名证书正确也不等于候选正确。一个错误版本可以使用正确证书签名,一个正确候选也可能在转移后被再次签名。身份门禁必须同时匹配文件摘要、包版本和证书摘要,并明确比对发生在最终上传前的本地文件上,而不是构建日志中的中间产物。
| 对象 | 责任 | 交接可记录 | 禁止进入交接包 |
|---|---|---|---|
| 应用签名密钥 | 应用长期更新身份 | 托管责任与证书摘要 | 私钥和口令 |
| 上传密钥 | 向平台提交授权 | 允许操作者与证书摘要 | 密钥导出物 |
| 签名证书 | 公开身份校验 | SHA-256 与有效主体 | 无 |
| 平台托管签名 | 最终分发签名 | 平台责任与回执 | 平台内部密钥 |
| 发布操作者 | 执行上传与复核 | 身份、时间和审批单 | 共享账号凭据 |
已签名 APK 不能在交接后继续改字节
Android apksigner 可以签名 APK、验证签名方案覆盖并输出签名证书信息,且签名后的 APK 若再被修改,签名会失效。交接流程应把对齐、渠道处理、资源生成和签名顺序固定在构建管线中。发布团队拿到 final-signed 候选后,只能执行只读校验与上传,不能再插入文件或调整归档内容。
若渠道必须添加元数据、采用单独签名或从 AAB 派生交付物,这些动作应被建模为新的构建阶段,而不是手工修补已验收文件。阶段输出必须获得新摘要和新身份清单,原候选的运行回执是否能复用要经过差异分析。不能把重新签名后的文件沿用旧 SHA-256 报告,也不能把 apksigner verify 成功写成业务兼容通过。
上传前需要重新执行 apksigner verify,并保存标准输出、工具版本、文件摘要和证书摘要。验证失败直接阻断;验证成功只支持签名结构与证书观察结果,不证明 keystore 管理、渠道选择、包版本或归档流程正确。多个 signer 或签名轮换场景要按应用历史和渠道规则核对,不可只取输出第一行。
| 动作 | 是否允许 | 原因 | 正确处理 |
|---|---|---|---|
| 计算摘要 | 允许 | 只读身份校验 | 记录工具和结果 |
| apksigner verify | 允许 | 只读签名校验 | 保存证书与方案输出 |
| 重命名文件 | 条件允许 | 不改字节但易混淆 | 摘要仍必须匹配 |
| 修改 ZIP 内容 | 禁止 | 会改变字节并破坏签名 | 回到构建与签名阶段 |
| 重新签名 | 禁止直接继承 | 生成新候选身份 | 新摘要、新证据、新批准 |
构建来源证明要与产物主体摘要相连
SLSA Provenance v1.1 把构建证明与产物主体、构建者、构建类型、外部参数和依赖材料绑定。交接方应让 provenance subject 的 sha256 与实际候选一致,并保留生成系统、构建配置和材料摘要。若证明指向 AAB 而上传岗位拿到的是另一个 APK,二者关系必须由受控派生记录解释。
来源证明回答“这个产物如何生成”,不能单独回答它是否兼容、安全或适合发布。发布团队仍需核对测试报告、签名验证、业务批准和渠道计划。证明中的外部参数若与冻结加固配置不一致,或者材料列表发生未解释变化,应阻断交接并让构建责任人确认。
实际流程可以使用平台生成的 provenance,也可以采用内部受控格式,但字段必须足以还原主体与构建上下文。截图、流水线 URL 和“任务成功”状态会随权限与保留期变化,不适合作为唯一证据。最低交付应保存声明文件摘要、可信签名或受控生成回执,以及与候选摘要的机器校验结果。
| 字段 | 回答的问题 | 错配信号 | 适用边界 |
|---|---|---|---|
| subject.digest | 证明对应哪个产物 | 与候选 SHA-256 不同 | 只绑定主体 |
| builder | 哪个受控系统生成 | 未知或未授权构建者 | 不证明运行安全 |
| buildType | 采用哪类流程 | 与约定管线不同 | 需结合配置 |
| externalParameters | 哪些外部输入生效 | 加固配置漂移 | 可能需要脱敏 |
| materials | 依赖哪些输入 | 材料摘要无解释变化 | 不代表依赖无漏洞 |
验收、来源和批准声明必须挂到同一 subject
in-toto Attestation Statement v1 使用 subject 与 predicateType 把产物摘要和有类型的声明负载联系起来。交接清单可以要求 compatibility、security-review、signing-verification 和 release-approval 等声明都把候选 SHA-256 放入 subject,发布岗位按 predicateType 检查必需声明是否齐全。
声明格式只解决关联问题,不保证声明者可信或内容真实。每类声明还要有允许的生成者、签名身份、有效时间、审核版本和 predicate schema。一个自称 release-approval 的 JSON 若由无权限账号生成,或者内容只写“测试完成”而没有范围和限制,不能关闭门禁。
多产物发布要避免主从关系丢失。例如同一版本包含 AAB、mapping、Native symbols 和渠道元数据,应分别计算摘要,并在发布清单中明确哪个是上传主体、哪些是诊断附件、它们与构建来源如何关联。附件摘要不匹配可能不改变上传字节,却会破坏后续崩溃还原和审计,也应在交接前解决。
| predicateType | 主体 | 核心内容 | 可信条件 |
|---|---|---|---|
| build-provenance | 上传候选 | 构建者、参数与材料 | 受控生成者和主体匹配 |
| compatibility | 同一候选 | 覆盖矩阵、结果与限制 | 测试责任人签署 |
| security-review | 同一候选 | 控制、证据与边界 | 授权审核者签署 |
| signing-verification | 最终签名文件 | 方案和证书摘要 | 上传前只读提取 |
| release-approval | 最终上传主体 | 渠道、版本和批准范围 | 业务与发布责任明确 |
发布清单要覆盖依赖服务、限制和回滚候选
Android publish your app 说明发布前需要配置 release 变体、构建签名产物、完成测试,并准备应用依赖的服务,上传只是发布过程的一环。交接包因此不能只有 APK 或 AAB,还应包含目标渠道、发布轨道、版本策略、服务端兼容窗口、功能开关、灰度条件和监控责任。
已知限制必须进入 release manifest,而不是散落在聊天记录。每项限制写清受影响设备、系统、ABI、功能或依赖,给出处置、责任人和是否阻断。商业上接受限制不等于技术问题消失,发布审批应明确接受范围;若限制超出批准范围,上传岗位没有权力现场放宽。
回滚也要绑定具体可发布候选。仅写“必要时回滚上一版”不够,因为上一版可能不符合当前渠道版本规则、服务端协议或数据迁移方向。交接时要记录 rollbackCandidateSha256、签名证书、版本、服务兼容条件和触发人,真正执行前仍按渠道和数据安全要求复核。
| 项目 | 必须记录 | 发布前确认 | 不能证明 |
|---|---|---|---|
| 渠道与轨道 | 商店、灰度或企业分发 | 账号和目标正确 | 审核一定通过 |
| 依赖服务 | API、配置与兼容窗口 | 服务已准备 | 所有网络环境正常 |
| 功能开关 | 默认值与回退策略 | 权限和审计存在 | 产品行为永不漂移 |
| 已知限制 | 范围、影响和责任人 | 批准范围一致 | 限制已解决 |
| 回滚候选 | 摘要、签名与版本 | 渠道和数据条件允许 | 回滚一定无损 |
上传前执行双人复核并保存渠道回执
上传前最后一次复核应由执行上传的人从本地实际文件计算摘要、提取 manifest 身份、运行签名验证,再由第二责任人对照冻结清单。双人复核不是两个人看同一张截图,而是执行者提交机器输出,复核者确认 candidateSha256、包版本、证书、渠道、批准单和限制完全一致。
文件传输要采用访问受控、可记录下载身份和保留版本的介质。下载后立即校验摘要,临时目录只保存当前候选,不从“Downloads”或聊天软件自动编号文件中直接上传。若发布工具会复制、转换或生成派生文件,要保存输入与输出摘要以及平台回执,不能假设工具内部没有改变对象。
上传完成后记录 channel、track、uploadedAt、operator、localArtifactSha256、platformReceipt 和审核状态。平台接受文件不等于已经公开发布,审核通过也不证明运行兼容。交接任务只在本地身份、上传回执和审批范围闭合后结束,业务上线与监控由后续发布流程负责。
| 检查点 | 执行者 | 输入 | 输出 |
|---|---|---|---|
| 下载校验 | 上传人员 | 受控交付链接 | 本地 SHA-256 |
| 身份提取 | 上传人员 | 本地实际文件 | 包版本与证书 |
| 双人复核 | 独立复核人 | 机器输出和冻结清单 | 签署的 releaseCandidateId |
| 平台上传 | 最小权限账号 | 已复核候选 | 渠道回执 |
| 交接关闭 | 发布负责人 | 回执、限制和监控计划 | 可审计完成记录 |
用只读脚本拒绝待上传文件的任何身份漂移
下方脚本接收 release manifest 和本地待上传 APK,重新计算 SHA-256,通过 apksigner 验证签名并提取全部 signer 证书摘要,再通过 apkanalyzer 读取 applicationId、versionCode 和 versionName。它还校验 manifest 中列出的 in-toto 风格声明文件摘要及 subject.sha256,任何字段不一致、工具失败或证据缺失都明确返回非零。
脚本只适用于 APK 交接身份门禁,不覆盖 AAB 派生签名、iOS 代码签名或具体商店策略,也不验证声明签名者的授权。apksigner 和 apkanalyzer 必须来自发布团队固定并记录版本的 Android 工具链。输出 passed 只证明身份元组与本地声明一致,不代表应用兼容、安全或审核通过。
NIST SP 800-218 SSDF 要求安全发布保留来源、构建、验证和变更证据。发布团队应把脚本版本、manifest 摘要、工具版本、机器输出、双人批准和上传回执一起归档,并限制访问敏感运营信息。需要建立实际加固发布交接时,准备候选、证书摘要、来源声明和发布计划,再通过御盾中央平台提交申请。
| 状态 | 含义 | 允许动作 | 禁止外推 |
|---|---|---|---|
| input-invalid | 文件或清单不可解析 | 修复输入 | 候选错误 |
| identity-mismatch | 摘要、版本或证书漂移 | 停止并回到交接 | 自动选择任一文件 |
| attestation-mismatch | 声明摘要或 subject 错配 | 补证或重建声明 | 产物运行失败 |
| handoff-identity-passed | 本地身份元组一致 | 进入双人复核与上传 | 兼容、安全或审核通过 |
| upload-receipted | 平台已接收指定文件 | 进入审核与监控 | 已经公开上线 |
- 待上传 APK 来自受控交付位置,并在本地重新计算 SHA-256
- 包名、versionCode、versionName 和全部 signer 证书摘要与冻结清单一致
- apksigner 与 apkanalyzer 来自固定 Android 工具链并记录版本
- 每份来源、验收和批准声明的文件摘要及 subject 都匹配候选
- 双人复核只看机器提取结果与冻结清单,不依赖截图或文件名
- 身份通过后仍按渠道、审批、限制、回滚和监控流程继续发布
#!/usr/bin/env python3
import hashlib
import json
import re
import shutil
import subprocess
import sys
from pathlib import Path
SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
CERT_RE = re.compile(r"Signer #\d+ certificate SHA-256 digest:\s*([0-9a-fA-F]+)")
def fail(message, code=2):
print(message, file=sys.stderr)
raise SystemExit(code)
def sha256_file(path):
digest = hashlib.sha256()
with path.open("rb") as handle:
for block in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def load_json(path, label):
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
fail("cannot read " + label + ": " + str(exc))
if not isinstance(value, dict):
fail(label + " must be a JSON object")
return value
def require_text(record, field, label):
value = record.get(field)
if not isinstance(value, str) or not value.strip():
fail(label + " is missing " + field)
return value.strip()
def run_tool(command, label):
completed = subprocess.run(
command,
check=False,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=45,
)
if completed.returncode != 0:
fail(label + " failed: " + completed.stderr.strip(), 10)
return completed.stdout.strip()
def apkanalyzer_value(apkanalyzer, apk, field):
return run_tool([apkanalyzer, "manifest", field, str(apk)], "apkanalyzer " + field).splitlines()[-1].strip()
def inspect_apk(apksigner, apkanalyzer, apk):
verify_output = run_tool([apksigner, "verify", "--verbose", "--print-certs", str(apk)], "apksigner verify")
certificates = sorted({match.lower() for match in CERT_RE.findall(verify_output)})
if not certificates:
fail("apksigner returned no certificate SHA-256 digest", 11)
return {
"artifactSha256": sha256_file(apk),
"packageName": apkanalyzer_value(apkanalyzer, apk, "application-id"),
"versionCode": apkanalyzer_value(apkanalyzer, apk, "version-code"),
"versionName": apkanalyzer_value(apkanalyzer, apk, "version-name"),
"certificateSha256s": certificates,
"signatureOutputSha256": hashlib.sha256(verify_output.encode("utf-8")).hexdigest(),
}
def resolve_under(root, relative, label):
candidate = (root / relative).resolve()
try:
candidate.relative_to(root.resolve())
except ValueError:
fail(label + " escapes manifest directory")
if not candidate.is_file():
fail(label + " does not exist")
return candidate
def validate_attestations(manifest, root, artifact_sha, findings):
records = manifest.get("attestations")
if not isinstance(records, list) or not records:
fail("attestations must be a non-empty array")
for index, record in enumerate(records):
label = "attestation[" + str(index) + "]"
if not isinstance(record, dict):
fail(label + " must be an object")
relative = require_text(record, "path", label)
expected_file_sha = require_text(record, "sha256", label).lower()
expected_type = require_text(record, "predicateType", label)
if not SHA256_RE.fullmatch(expected_file_sha):
fail(label + " has invalid sha256")
path = resolve_under(root, relative, label)
if sha256_file(path) != expected_file_sha:
findings.append({"kind": "attestation-file-hash-mismatch", "path": relative})
continue
statement = load_json(path, label)
if statement.get("predicateType") != expected_type:
findings.append({"kind": "predicate-type-mismatch", "path": relative})
subjects = statement.get("subject", [])
if not isinstance(subjects, list):
fail(label + " subject must be an array")
subject_hashes = {
str(subject.get("digest", {}).get("sha256", "")).lower()
for subject in subjects
if isinstance(subject, dict)
}
if artifact_sha not in subject_hashes:
findings.append({"kind": "attestation-subject-mismatch", "path": relative})
def compare_identity(expected, observed, findings):
for field in ("artifactSha256", "packageName", "versionCode", "versionName"):
expected_value = str(expected.get(field, "")).strip()
if not expected_value:
fail("release manifest is missing " + field)
if expected_value != observed[field]:
findings.append({"kind": "identity-mismatch", "field": field, "expected": expected_value, "observed": observed[field]})
expected_certs = expected.get("certificateSha256s")
if not isinstance(expected_certs, list) or not expected_certs:
fail("certificateSha256s must be a non-empty array")
normalized = sorted({str(item).lower() for item in expected_certs})
if any(not SHA256_RE.fullmatch(item) for item in normalized):
fail("certificateSha256s contains an invalid digest")
if normalized != observed["certificateSha256s"]:
findings.append({"kind": "certificate-set-mismatch", "expected": normalized, "observed": observed["certificateSha256s"]})
def main():
if len(sys.argv) != 3:
print("Usage: verify_release_handoff.py RELEASE_MANIFEST_JSON UPLOAD_APK", file=sys.stderr)
raise SystemExit(2)
manifest_path = Path(sys.argv[1])
upload_apk = Path(sys.argv[2])
if not manifest_path.is_file() or not upload_apk.is_file():
print("release manifest and upload APK must exist", file=sys.stderr)
raise SystemExit(2)
apksigner = shutil.which("apksigner")
apkanalyzer = shutil.which("apkanalyzer")
if not apksigner or not apkanalyzer:
fail("fixed Android apksigner and apkanalyzer tools are required", 3)
manifest = load_json(manifest_path, "release manifest")
observed = inspect_apk(apksigner, apkanalyzer, upload_apk)
findings = []
compare_identity(manifest, observed, findings)
validate_attestations(manifest, manifest_path.parent, observed["artifactSha256"], findings)
result = {
"status": "identity-mismatch" if findings else "handoff-identity-passed",
"manifestSha256": sha256_file(manifest_path),
"observedIdentity": observed,
"findings": findings,
"boundary": "Identity matching does not prove compatibility, security, store approval, or public release.",
}
print(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True))
if findings:
raise SystemExit(20)
if __name__ == "__main__":
main()事实依据与适用边界
以下内容区分官方事实、本文工程判断和不能外推的范围,避免把设计建议写成未经验证的产品结论。
| 本文判断 | 事实或工程依据 | 适用限制 |
|---|---|---|
| Android 发布需要区分应用签名密钥、上传密钥、证书和平台托管签名责任。 | Sign your Android app 说明 Android 应用签名与 Play App Signing。 | 文档不能确认某个实际候选使用了正确生产证书。 |
| apksigner 可签名、验证签名方案与证书,签名后修改 APK 会使签名失效。 | Android apksigner 说明 APK 签名与验证工具行为。 | 工具验证成功不证明密钥管理、渠道流程、候选归档或业务兼容正确。 |
| 构建证明可以绑定产物主体、构建者、构建类型、外部参数和依赖材料。 | SLSA Provenance v1.1 定义来源证明的数据模型。 | provenance 只支持记录的构建过程,不能单独证明运行时安全。 |
| 供应链声明可以把产物摘要和有类型的声明负载关联。 | in-toto Attestation Statement v1 定义 subject 与 predicateType。 | 声明格式不保证内容真实,仍需可信签名者与门禁核验。 |
| 发布前需要配置 release 变体、构建签名产物、完成测试并准备依赖服务。 | Android publish your app 描述 Android 应用发布流程。 | 通用指南不证明某个交付包可直接上架,也不替代具体商店审核。 |
| 安全发布应保留来源、构建、验证和变更证据并关注供应链风险。 | NIST SP 800-218 SSDF 给出组织级安全软件开发实践。 | SSDF 不定义御盾或任何具体 App 加固产品的功能。 |
| 交付、验收、签名验证和上传回执必须引用同一个候选 SHA-256。 | 工程判断:摘要是跨系统识别同一字节产物的可复核主键。 | 摘要一致只证明文件身份一致,不证明文件兼容、安全或审核通过。 |
| 上传岗位应从本地实际文件重新提取身份元组,并由第二责任人复核。 | 工程判断:独立提取可以发现文件名、表格或传输环节造成的候选错配。 | 双人复核不能替代密钥托管、渠道权限、应用测试和上线监控。 |
工程常见问题
文件名、包名和版本号都一致,为什么还要比 SHA-256?
这些字段可以重复,文件名还能被任意修改。SHA-256 直接绑定实际字节,能发现同名覆盖、重签、渠道注入或传输替换,是跨岗位关联证据的主键。
apksigner verify 成功是否说明可以直接上传?
不能。它支持签名结构与证书校验,还要核对候选摘要、包版本、构建来源、验收、渠道、限制、依赖服务和发布批准。
发布团队可以给已签名 APK 增加渠道文件吗?
不应手工修改。签名后改变 APK 字节会破坏签名;确需渠道处理时,应回到受控构建和签名阶段,生成新摘要、新证据和新批准。
使用 Play App Signing 时应比对哪个证书?
要按责任区分上传密钥证书和最终应用签名证书。上传文件与平台分发产物的验证对象不同,交接清单必须明确各自预期身份。
来源证明 subject 匹配是否说明加固效果已经验证?
不说明。subject 匹配只把声明与正确候选关联,来源证明也主要描述构建过程;运行兼容、安全控制和业务结果仍需各自证据。
建立加固发布交接需要准备哪些材料?
准备最终候选、摘要、包版本、证书摘要、构建来源、验收声明、目标渠道、已知限制、依赖服务、回滚候选和审批责任,再通过御盾中央平台提交申请。