先看结论与判断条件

  • 交接主键是待发布文件的 SHA-256,而不是文件名、网盘链接、构建任务号或聊天记录;所有报告与批准都必须引用同一摘要。
  • 发布团队要区分应用签名密钥、上传密钥、证书和平台托管签名责任,交接清单只记录允许公开的证书摘要,不传递私钥或口令。
  • 已经完成 APK 签名的文件不得再修改;任何 zipalign、资源替换、渠道注入或重打包动作都要回到既定签名顺序并生成新候选。
  • 来源证明和声明应把产物主体摘要与构建者、构建类型、参数、材料及验收负载绑定,格式正确仍需核对签名者和内容可信度。
  • 上传岗位必须从本地实际文件重新计算摘要、读取包版本、执行签名验证和提取证书,不能只对照交付方发来的截图或表格。
  • 上传成功只是发布流程中的一个环节;最终记录还要绑定渠道、版本、时间、操作者、回执和回滚候选,且不得把错配门禁通过写成上架审核通过。

交接的目标是让同一个文件贯穿验收、签名和上传

软件加固完成后,研发、测试、安全、签名和发布岗位经常通过不同系统传递产物。文件可能被重命名、压缩、解压、再次签名或由另一个构建任务覆盖。真正危险的不是交接步骤多,而是各岗位只认文件名和版本号,导致测试通过的候选与最终上传文件不是同一个字节对象。

受控交接应建立 releaseCandidateId,并以 SHA-256 作为不可变主键,同时保存包名、versionCode、versionName、签名证书摘要、格式、目标渠道、构建来源和验收报告摘要。后续每份批准单、测试回执、来源声明与上传记录都引用该主键。文件名可以服务人类识别,但任何同名不同摘要都必须被视为新候选。

工程判断上,发布岗位的任务不是相信上一岗位,而是对手中的实际文件独立提取身份元组并与冻结清单比较。本文没有实际御盾候选、证书和渠道回执,因此只提供交接门禁方法,不声称任何具体包已经完成签名、验收或上架。

加固发布候选的身份元组
字段从哪里取得用于阻止什么漂移处置
artifactSha256实际交付文件同名替换与字节变化立即停止
packageNameAPK Manifest上传错误应用返回构建方
versionCode/versionNameAPK Manifest版本序列错乱核对发布计划
certificateSha256签名验证输出错误签名身份禁止上传
provenanceSubject来源证明 subject报告与产物错挂重新生成或补证

签名责任必须区分应用密钥、上传密钥和证书

Sign your Android app 说明 Android 发布需要理解应用签名密钥、上传密钥、证书以及 Play App Signing 下的责任分工。交接清单应明确当前文件由谁签名、预期证书摘要是什么、渠道是否还会执行托管签名,以及上传岗位是否只持有上传授权。不能把“生产签名”当成一个没有主体的勾选项。

证书包含可用于识别签名者的公开信息,私钥则必须保持秘密。交接包可以记录证书 SHA-256、别名对应的责任角色和密钥托管系统回执,但不应携带 keystore、私钥、口令、临时令牌或恢复材料。发布人员只获得完成岗位职责所需的最小权限,密钥调用应由受控系统记录。

签名证书正确也不等于候选正确。一个错误版本可以使用正确证书签名,一个正确候选也可能在转移后被再次签名。身份门禁必须同时匹配文件摘要、包版本和证书摘要,并明确比对发生在最终上传前的本地文件上,而不是构建日志中的中间产物。

Android 签名交接责任
对象责任交接可记录禁止进入交接包
应用签名密钥应用长期更新身份托管责任与证书摘要私钥和口令
上传密钥向平台提交授权允许操作者与证书摘要密钥导出物
签名证书公开身份校验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 都匹配候选
  • 双人复核只看机器提取结果与冻结清单,不依赖截图或文件名
  • 身份通过后仍按渠道、审批、限制、回滚和监控流程继续发布
比较交付清单与待上传 APK 的身份元组
#!/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 匹配只把声明与正确候选关联,来源证明也主要描述构建过程;运行兼容、安全控制和业务结果仍需各自证据。

建立加固发布交接需要准备哪些材料?

准备最终候选、摘要、包版本、证书摘要、构建来源、验收声明、目标渠道、已知限制、依赖服务、回滚候选和审批责任,再通过御盾中央平台提交申请。

想用自己的 App 验证?

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

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