APK签名工具链
签名工具的输出不是“给 APK 加一个证书”。它同时改变 ZIP 条目、META-INF、APK Signing Block、 EOCD 偏移和可选的 .idsig 工件;设备侧又会按最低签名方案、文件格式和完整性摘要重新验证。只看 apksigner sign 命令,很难解释为什么签名后再 zipalign 会失效,为什么 v4 验证仍要依赖 APK 内的 v2/v3,或者为什么同一个证书在更新安装时仍可能被拒绝。
本文以 Android 17 checkout 的源码为准。生成入口是 build/make/tools/signapk/src/com/android/signapk/SignApk.java,它创建 DefaultApkSignerEngine 并消费 ApkSignerEngine 请求;设备侧入口是 frameworks/base/core/java/android/util/apk/ApkSignatureVerifier.java。本文不把某个 SDK Build Tools 发行包的 CLI 参数表当作源码教学,也不展开完整证书密码学算法;重点是从 APK 输入到设备验证结果的 字节、对象和失败路径。
本文面向已经理解 APK 安装事务、但还不能把签名文件布局与设备验证连接起来的读者;安装 session 的 前置边界见 ADB安装与权限管理。读完后,应能从 SignApk.main 追到 v1/v2/v3/v4 工件,再从 ApkSignatureVerifier 追到 SigningDetails 消费者, 并用一次篡改实验区分“证书可读取”“摘要完整”和“更新身份兼容”。
1. 生成端与消费端
SignApk 负责读取私钥/证书、解析输入 APK、生成 v1 JAR 条目和 v2/v3 Signing Block,并在启用时生成 v4 工件。ApkSignatureVerifier 不读取 SignApk 的 Java 对象,而是从最终 APK 的 ZIP 区段和签名块 重建证书、摘要和 SigningDetails。两边通过文件格式连接,而不是共享内存状态。
PackageParser/ParsingPackageUtils 在解析 APK 时调用 verifier,并将结果交给包解析和安装路径。因而 签名工具的“成功”只表示生成端写出了格式正确的工件;最终是否能安装、更新或获得签名权限,要等 framework 消费 SigningDetails 后才能判断。
2. 输入解析
旧版 SignApk 的命令行解析仍保留在 Android 17 源码中。默认同时打开 v1 和 v2,v4 需要显式启用; 最低 SDK 可以从 AndroidManifest.xml 推断,也可由参数覆盖。这个最低版本会影响签名引擎选择兼容 方案,而不是改变证书本身。
源码文件:build/make/tools/signapk/src/com/android/signapk/SignApk.java
// build/make/tools/signapk/src/com/android/signapk/SignApk.java :: main
boolean signUsingApkSignatureSchemeV1 = true;
boolean signUsingApkSignatureSchemeV2 = true;
boolean signUsingApkSignatureSchemeV4 = false;
// ... 解析命令行选项
if (signUsingApkSignatureSchemeV4) {
// v4 需要独立的 idsig 输出;没有可用条件时由后续流程报告错误。
outputV4Signature = new File(outputFilename + ".idsig");
}这段状态属于 host 工具。它不会告诉设备“必须使用 v2”或“v3 一定存在”;设备验证器仍会根据 APK 中 实际存在的 block、调用方要求的最低方案和当前平台能力决定接受哪一条路径。也就是说,--v2-signing-enabled 是生成策略,不是安装策略。
3. ZIP签名
签名时必须先完成可签名的 ZIP 内容,再写入签名材料。findMainZipSections 将输出文件拆成 beforeCentralDir、Central Directory 和 EOCD;v2/v3 block 插入在前两者之间,所以 EOCD 中的 Central Directory offset 必须随之调整。
源码文件:build/make/tools/signapk/src/com/android/signapk/SignApk.java
// build/make/tools/signapk/src/com/android/signapk/SignApk.java :: findMainZipSections
ApkUtils.ZipSections sections = ApkUtils.findZipSections(apk);
long centralDirStartOffset = sections.getZipCentralDirectoryOffset();
long eocdStartOffset = sections.getZipEndOfCentralDirectoryOffset();
if (centralDirStartOffset + sections.getZipCentralDirectorySizeBytes()
!= eocdStartOffset) {
throw new ZipFormatException("ZIP Central Directory is not immediately followed by "
+ "End of Central Directory");
}
result.beforeCentralDir = apk.slice(0, centralDirStartOffset);
result.centralDir = apk.getByteBuffer(centralDirStartOffset, centralDirSize);
result.eocd = apk.getByteBuffer(eocdStartOffset, eocdSize);校验 Central Directory 与 EOCD 相邻,是为了保证后续插入 Signing Block 时偏移关系可计算。输入 APK 结构不满足这个不变量时,工具应停止,而不是生成一个设备侧再失败的 APK。
4. v1条目请求
DefaultApkSignerEngine 先处理 ZIP 条目。outputJarEntries() 返回 v1 请求后,SignApk 调用 addV1Signature 将 MANIFEST.MF、.SF 和证书条目写入输出 JAR。v1 保护的是条目摘要,不直接覆盖 Central Directory 与 EOCD 元数据。
源码文件:build/make/tools/signapk/src/com/android/signapk/SignApk.java
// build/make/tools/signapk/src/com/android/signapk/SignApk.java :: sign APK main path
try (ApkSignerEngine apkSigner = builder.build()) {
// 输入 APK 的旧 Signing Block 不复用,避免把旧签名带入新输出。
apkSigner.inputApkSigningBlock(null);
JarOutputStream outputJar = new JarOutputStream(outputFile);
copyFiles(inputJar, null, apkSigner, outputJar, outputJarCounter, timestamp, alignment);
ApkSignerEngine.OutputJarSignatureRequest request = apkSigner.outputJarEntries();
if (request != null) {
addV1Signature(apkSigner, request, outputJar, timestamp);
request.done();
}
outputJar.close();
}request.done() 是生命周期边界:它告诉签名引擎 v1 请求已经被消费。若在完成请求前关闭输出,证书 条目可能不完整;若保留输入 Signing Block,则会把已失效的旧摘要与新 ZIP 内容混在一起。因此“先复制 条目、再请求签名、最后关闭 JAR”不是风格选择,而是引擎协议的消费顺序。
5. v2/v3 Block
v2/v3 不是普通 ZIP entry,而是插入 Central Directory 前的特殊块。outputZipSections2 接收已经 写完的 beforeCentralDir、Central Directory 和 EOCD,返回 block 字节及所需 padding;调用方再把 block 写回输出,并修改 EOCD 的 Central Directory offset。
源码文件:build/make/tools/signapk/src/com/android/signapk/SignApk.java
// build/make/tools/signapk/src/com/android/signapk/SignApk.java :: v2/v3 output loop
ApkSignerEngine.OutputApkSigningBlockRequest2 addV2SignatureRequest =
apkSigner.outputZipSections2(
zipSections.beforeCentralDir,
DataSources.asDataSource(zipSections.centralDir),
DataSources.asDataSource(eocd));
if (addV2SignatureRequest == null) break;
int padding = addV2SignatureRequest.getPaddingSizeBeforeApkSigningBlock();
byte[] apkSigningBlock = addV2SignatureRequest.getApkSigningBlock();
ByteBuffer modifiedEocd = ByteBuffer.allocate(eocd.remaining());
modifiedEocd.put(eocd);
modifiedEocd.flip();
modifiedEocd.order(ByteOrder.LITTLE_ENDIAN);
ApkUtils.setZipEocdCentralDirectoryOffset(
modifiedEocd,
zipSections.beforeCentralDir.size() + padding + apkSigningBlock.length);
addV2SignatureRequest.done();v2 和 v3 的差异主要在 Signing Block 中的 signer 数据:v3 还携带 proof-of-rotation,允许设备把新 证书与历史证书联系起来。文件布局层面两者都依赖同一个“block 位于 ZIP Central Directory 前”的不变量。 因此对已签名 APK 再运行会改变被摘要的区段,必须重新生成签名,不能只替换一个证书文件。
6. v4独立工件
v4 不把 Merkle 根和签名写回 APK,而是写入同目录的 .idsig。它服务于增量文件系统的块级验证, 但设备仍需要 APK 内的签名方案提供证书身份和整体关系。SignApk 在输出 APK 完成后才进入 v4 路径, 所以 .idsig 的生成不能替代 APK 本体签名。
源码文件:build/make/tools/signapk/src/com/android/signapk/SignApk.java
// build/make/tools/signapk/src/com/android/signapk/SignApk.java :: v4 output path
if (signUsingApkSignatureSchemeV4) {
final DataSource outputApkIn = DataSources.asDataSource(
new RandomAccessFile(new File(outputFilename), "r"));
final File outputV4File = new File(outputV4Filename);
apkSigner.signV4(outputApkIn, outputV4File, false /* ignore failures */);
}v4 请求为空或写入失败时,APK 文件可能已经生成;调用方必须把 APK 与 .idsig 作为一组工件处理, 不能只上传其中一个。该路径的生效时机发生在增量安装消费者读取 idsig 时,而不是普通 ZIP 解析时。
7. 设备验证降级
设备侧入口 verifySignaturesInternal 按 v4、v3.1/v3、v2、v1 的顺序尝试。SignatureNotFoundException 表示该方案不存在,可以降级;解析错误、摘要不匹配或签名密码学验证失败则属于失败,不应静默当成“没有 这个方案”。
源码文件:frameworks/base/core/java/android/util/apk/ApkSignatureVerifier.java
// frameworks/base/core/java/android/util/apk/ApkSignatureVerifier.java :: verifySignaturesInternal
try {
return verifyV4Signature(input, apkPath, minSignatureSchemeVersion, verifyFull);
} catch (SignatureNotFoundException e) {
// not signed with v4, try older if allowed
if (minSignatureSchemeVersion >= SignatureSchemeVersion.SIGNING_BLOCK_V4) {
return input.error(INSTALL_PARSE_FAILED_NO_CERTIFICATES,
"No APK Signature Scheme v4 signature in package " + apkPath, e);
}
}
return verifyV3AndBelowSignatures(
input, apkPath, minSignatureSchemeVersion, verifyFull);verifyV3AndBelowSignatures 内部以同样的 try/catch SignatureNotFoundException 结构依次尝试 v3 和 v2, 最后才调用 v1。这里的“降级”只针对缺失方案或版本条件,不是对篡改内容放宽要求。v2/v3 verifier 在解析 signer 后还会 调用 ApkSigningBlockUtils.verifyIntegrity;任何摘要不匹配都会使该路径失败。成功返回的消费者是 SigningDetailsWithDigests,随后被 PackageParser、PackageSessionVerifier 和权限/更新检查使用。
图中的“缺失”可以触发下一方案,“解析失败”和“完整性异常”不能被当成缺失。这个区分是安装错误 诊断的关键:没有 v3 但有合法 v2 是兼容降级;v3 block 存在却摘要不匹配,则是被篡改或生成链损坏。
8. v2完整性校验
v2 verifier 先读取长度前缀的 signer 列表,限制 signer 数量,要求至少一个 signer 和至少一个 content digest,然后再做完整性校验。这个顺序把“签名结构可解析”和“APK 字节未被修改”分成两个状态。
源码文件:frameworks/base/core/java/android/util/apk/ApkSignatureSchemeV2Verifier.java
// frameworks/base/core/java/android/util/apk/ApkSignatureSchemeV2Verifier.java :: verify
while (signers.hasRemaining()) {
signerCount++;
if (signerCount > MAX_V2_SIGNERS) {
throw new SecurityException("APK Signature Scheme v2 only supports a maximum of "
+ MAX_V2_SIGNERS + " signers");
}
ByteBuffer signer = getLengthPrefixedSlice(signers);
signerCerts.add(verifySigner(signer, contentDigests, certFactory));
}
if (signerCount < 1) {
throw new SecurityException("No signers found");
}
if (contentDigests.isEmpty()) {
throw new SecurityException("No content digests found");
}
if (doVerifyIntegrity) {
ApkSigningBlockUtils.verifyIntegrity(contentDigests, apk, signatureInfo);
}verifySigner 还会比较签名算法、证书公钥和 signed data 中的摘要。读者排查“签名块存在但安装失败” 时,应区分 signer 结构错误、证书/公钥不一致和 APK 内容摘要错误,三者都不是简单的“缺少 v2”。
9. 证书消费者
verifier 返回的不是布尔值,而是签名证书、签名方案版本、历史证书和 content digest。SigningDetails 会被已安装包记录,并在更新时比较新旧签名;v3 的 proof-of-rotation 让旧证书历史可以携带能力,而不是 把“证书相同”简化为单个字节数组比较。
源码文件:frameworks/base/core/java/android/content/pm/SigningDetails.java
// frameworks/base/core/java/android/content/pm/SigningDetails.java :: hasCertificate
public boolean hasCertificate(@NonNull Signature signature,
@CertCapabilities int flags) {
return hasCertificateInternal(signature, flags);
}
private boolean hasCertificateInternal(@NonNull Signature signature, int flags) {
if (this == UNKNOWN) return false;
if (hasPastSigningCertificates()) {
for (int i = 0; i < mPastSigningCertificates.length - 1; i++) {
if (mPastSigningCertificates[i].equals(signature)) {
if (flags == PAST_CERT_EXISTS
|| (flags & mPastSigningCertificates[i].getFlags()) == flags) {
return true;
}
}
}
}
return mSignatures.length == 1 && mSignatures[0].equals(signature);
}循环刻意排除历史数组的最后一个元素,因为源码把当前证书也放在该数组末尾;当前 signer 在最后一行单独 判断,并自动拥有全部 capability。历史证书则必须满足“只检查是否存在”或 flags 全包含条件。当前签名与 历史签名的消费语义不同:当前证书决定新 APK 身份,历史证书只有在 proof-of-rotation 标记授予相应 capability 时才能参与权限、共享 UID 或已安装数据兼容判断。签名工具生成 lineage 并不自动 保证更新成功,framework 仍会检查 capability 和安装上下文。
10. 失败与清理
| 阶段 | 失败输入 | 结果 | 恢复边界 |
|---|---|---|---|
| 读取密钥 | 密码错误、证书与私钥不匹配 | host 终止,不应发布输出 | 修正密钥输入后重新签名 |
| ZIP解析 | Central Directory/EOCD 不连续 | 不创建可信签名块 | 修复 APK 结构,不手工改 offset |
| v1请求 | 输出 JAR 未完成或请求未 done() | META-INF 不完整 | 删除输出并重跑,不复用半成品 |
| v2/v3请求 | block 写入或 EOCD 调整失败 | APK 可能已截断 | 校验文件长度和 ZIP,再重新生成 |
| v4请求 | .idsig 缺失/写入失败 | APK 与 idsig 不成组 | 清理孤立 idsig,重新生成整组工件 |
| 设备验证 | digest、签名或证书错误 | 安装解析失败 | 回到生成端比较方案、证书和对齐顺序 |
| 更新检查 | 新旧 SigningDetails 不兼容 | 更新被拒绝 | 检查 lineage/capability,不降低设备校验 |
签名工具不拥有设备上的已安装包状态;它只能保证输出工件符合生成协议。安装失败后的第一份证据应是 apksigner verify --verbose(或等价 verifier 输出)、证书摘要、APK/idsig 是否成组,以及设备侧解析 错误,而不是只重签一次。
11. 测试与验证
源码测试和 fixture 应分层理解。SignApk 的主要证据是 ApkSignerEngine 请求消费顺序和生成器分支; framework 侧 verifier 的对应测试来自签名解析、摘要校验和 Package Manager 测试资源。它们证明正常/失败 分支的控制流和格式约束,不证明任意厂商 keystore、所有 SDK Build Tools 发行版本或真实设备的增量安装。
读者可以用一个最小实验建立闭环:
# 仅在测试 APK 和测试密钥上执行
apksigner sign --ks test.jks --ks-key-alias test --out signed.apk unsigned.apk
apksigner verify --verbose --print-certs signed.apk
unzip -l signed.apk | sed -n '/META-INF/p'然后复制 APK 后修改一个非签名工具会重写的字节,再次验证;预期是 v2/v3 完整性失败,而不是“仍能读取 证书”。再把 --v1-signing-enabled true --v2-signing-enabled false 的结果交给不同 min SDK 的验证 环境,观察方案存在性与完整性检查的区别。实验只能证明所用工具和输入,不能外推到所有平台策略。
源码阅读练习是从 SignApk.main 追到 outputZipSections2,再从 ApkSignatureVerifier.verifySignaturesInternal 追到 ApkSigningBlockUtils.verifyIntegrity,最后说明 SigningDetails 哪些字段被更新检查消费。完成这条链,才能判断问题发生在 ZIP 结构、签名请求、摘要验证 还是证书兼容性,而不是把所有安装错误归为“签名不对”。
