跳到主要内容
ecdsa.com
← 全部文章

APK 签名方案 v1~v4、密钥轮换与 Play

我们用 build-tools 37.0.0 中的 apksigner 验证了一个真实的 APK,用自己的密钥重新签名了副本,轮换了密钥,添加了后量子 ML-DSA-65 签名者,还故意破坏了一个签名。下文的每一段输出都是真实输出。

简短回答:Android 会根据设备检查几种签名格式中的一种:v1(JAR 签名,逐文件签名,适用于 Android 6 及更早版本)、v2(存放在 APK 签名分块中的全文件签名,Android 7.0)、v3(v2 加上轮换证明谱系,Android 9)、v3.1(只面向 Android 13 及更高版本的轮换)、v3.2(经典算法与 ML-DSA 的混合签名,Android 17)和 v4(用于增量安装的单独 .idsig 文件,Android 11)。目标 API 级别为 30 或更高的应用必须具备 v2 或更新方案的签名。更新必须由同一个密钥签名,或由应用轮换谱系中的后继密钥签名,因此签名密钥在应用的整个生命周期中都是它的身份:让 Google Play 保管签名密钥(Play 应用签名),自己只保留上传密钥,并用 apksigner verify -v --print-certs 检查发布版本。

实验使用 Android SDK build-tools 37.0.0 中的 apksigner(它自报的版本号为 0.9),运行环境为 eclipse-temurin:25.0.4.1_1-jdk-noble 中的 Java 25.0.4.1。样本是2026年10月22日从 f-droid.org 下载的 F-Droid 客户端 APK;其余密钥均为本文专门生成。

签名方案一览

方案起始版本位置新增内容
v1Android 1.0META-INF/*.SF、*.RSA/*.ECJAR 签名:每个 ZIP 条目一个哈希;不覆盖 ZIP 元数据
v2Android 7.0(API 24)APK 签名分块,ID 0x7109871a对除分块本身以外的整个文件签名
v3Android 9(API 28)ID 0xf05368c0在 v2 基础上增加支持的 SDK 范围和轮换证明谱系
v3.1Android 13(API 33)新的分块 ID轮换只在 Android 13 及更高版本上生效
v3.2Android 17(API 37)ID 0x70e1c89f恰好两个签名者,一个经典、一个后量子,两者都要验证
v4Android 11(API 30)APK 旁边的 app.apk.idsig基于 APK 的 Merkle 树,供 adb install --incremental 使用;需要 v2 或 v3

APK 签名方案 v2“将 APK 文件视为 blob,并对整个文件执行签名检查”,因此“对 APK 进行的任何修改(包括对 ZIP 元数据的修改)都会使 APK 签名作废”(AOSP)。它也更快:设备不必再逐个解压条目来检查。v1 只为早于 Android 7.0 的设备保留;minSdkVersion 为 24 或更高的 APK 不需要它,apksigner 也会省略它。保留 v1 并不能掩盖被剥离的 v2 签名:同时带有 v1 签名的 v2 签名 APK 会在其 .SF 文件中携带 X-Android-APK-Signed 属性,而该属性在 v1 签名的覆盖范围内;如果 APK 声明了某个方案却缺少该方案的签名,验证方必须拒绝它。

从 Android 11 开始,“如果目标平台为 Android 11(API 级别 30)的应用目前仅使用 APK 签名方案 v1 签名,现在还必须使用 APK 签名方案 v2 或更高版本签名”(行为变更)。

解读 apksigner verify 的输出

apksigner verify -v --print-certs 会针对 APK 所支持的 SDK 范围检查它携带的每种方案,并打印签名者。以已发布的 F-Droid 客户端为例(输出已筛选,只保留方案和签名者相关的行):

apksigner verify -v --print-certs F-Droid.apk
Verifies
Verified using v1 scheme (JAR signing): false
Verified using v2 scheme (APK Signature Scheme v2): true
Verified using v3 scheme (APK Signature Scheme v3): true
Verified using v3.1 scheme (APK Signature Scheme v3.1): false
Verified using v3.2 scheme (APK Signature Scheme v3.2): false
Verified using v4 scheme (APK Signature Scheme v4): false
Verified for SourceStamp: false
Number of signers: 1
V3.0 Signer: certificate DN: CN=Ciaran Gultnieks, OU=Unknown, O=Unknown, L=Wetherby, ST=Unknown, C=UK
V3.0 Signer: certificate SHA-256 digest: 43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab
V3.0 Signer: key algorithm: RSA

v1 显示 false 并不是错误:该应用的最低 SDK 为 24,因此不需要 v1。证书摘要才是识别应用签名者的值:F-Droid 在其签名密钥页面上为客户端公布的正是 43238d51…a9c9ccab,而 Android 在每次更新时都会比对签名者。这里没有信任链:APK 证书通常是自签名的,v2 验证规则检查第一张证书的 SubjectPublicKeyInfo 与签名密钥是否一致,但没有检查颁发者或有效期的步骤(v2)。这张证书签发于2010年。

用我们自己的 P-256 密钥重新签名一个副本,再修改文件中的一个字节,就能看到这项检查的两个方面:

lab.sh(节选)
keytool -genkeypair -keystore key1.p12 -storetype PKCS12 -storepass changeit -alias app \
  -dname "CN=Example App signing key 2026" -validity 10000 -keyalg EC -groupname secp256r1 -sigalg SHA256withECDSA
apksigner sign --ks key1.p12 --ks-pass pass:changeit --out app-key1.apk F-Droid.apk
apksigner verify -v --print-certs app-key1.apk
cp app-key1.apk tampered.apk
printf '\x00' | dd of=tampered.apk bs=1 seek=4000000 conv=notrunc status=none
apksigner verify -v tampered.apk
输出(已筛选)
Verifies
Verified using v2 scheme (APK Signature Scheme v2): true
Verified using v3 scheme (APK Signature Scheme v3): true
V3.0 Signer: certificate DN: CN=Example App signing key 2026
V3.0 Signer: certificate SHA-256 digest: a93143a06f88894887d593f080c05b4ffd3aa9839c52378cee687987162d6d72
V3.0 Signer: key algorithm: EC

DOES NOT VERIFY
ERROR: APK Signature Scheme v3 signer #1: APK integrity check failed. CHUNKED_SHA256 digest mismatch. Expected: <66b1ea5c6952adc0c70f086346a76da06a5ab35dac770d104ff14d5ec09a9c97>, actual: <a5b1f3e628e868c150ade8cf1f3280f54ff4ae3771f20620cafbe97ad72ced57>

重新签名的副本完全通过了验证,只是证书不同。正因如此,判断 APK 是否来自正确的发布者,要看签名者摘要,而不是“Verifies”。如果把它覆盖安装到原版 F-Droid 上,Android 会把它视为由另一个密钥签名的更新而拒绝安装。CHUNKED_SHA256 摘要是对文件各部分中连续的 1 MB 数据块计算的,正是这一点让 v2 及更高版本能够快速、并行地完成验证。

轮换密钥:v3 与 v3.1

应用不能简单地通过重新签名来更换签名密钥:这样的更新会被拒绝。v3 增加了轮换证明(proof-of-rotation),即“一个单向链表,其中每个节点都包含一张曾用于为应用的先前版本签名的签名证书”,每张证书都为下一张签名(v3)。认得旧密钥的设备,只要谱系把新旧两个密钥连接起来,就会接受由新密钥签名的 APK。

轮换 key1 -> key2 并使用谱系签名
apksigner rotate --out lineage.bin --old-signer --ks key1.p12 --ks-pass pass:changeit \
  --new-signer --ks key2.p12 --ks-pass pass:changeit
apksigner sign --ks key1.p12 --ks-pass pass:changeit --next-signer --ks key2.p12 --ks-pass pass:changeit \
  --lineage lineage.bin --out app-rotated.apk F-Droid.apk
apksigner verify -v --print-certs app-rotated.apk
apksigner lineage --in app-rotated.apk --print-certs
输出(已筛选;省略 SHA-1 和 MD5 摘要)
Verified using v3 scheme (APK Signature Scheme v3): true
Verified using v3.1 scheme (APK Signature Scheme v3.1): true
V3.1 Signer: (minSdkVersion=33, maxSdkVersion=2147483647) certificate DN: CN=Example App signing key 2027
V3.1 Signer: (minSdkVersion=33, maxSdkVersion=2147483647) key algorithm: EC
V3.0 Signer: (minSdkVersion=24, maxSdkVersion=32) certificate DN: CN=Example App signing key 2026
V3.0 Signer: (minSdkVersion=24, maxSdkVersion=32) key algorithm: EC
Signer #1 in lineage certificate DN: CN=Example App signing key 2026
Has installed data capability: true
Has shared UID capability    : true
Has permission capability    : true
Has rollback capability      : false
Has auth capability          : true
Signer #2 in lineage certificate DN: CN=Example App signing key 2027
Has installed data capability: true
Has shared UID capability    : true
Has permission capability    : true
Has rollback capability      : false
Has auth capability          : true

默认情况下,apksigner 会把新密钥放进面向 Android 13 及更高版本的 v3.1 分块,而在面向 Android 7 至 12 的 v3.0 分块中保留旧密钥。这是有意为之:AOSP 指出,“对于 Android 12(API 级别 31)及更低版本,不建议轮换 APK 密钥”,因为从 Android 13 起,checkSignatures 才会识别谱系并“返回最新的签名”。--rotation-min-sdk-version 28 会强制在 Android 9 及更高版本上应用轮换;apksigner 文档把这一选项留给已经在旧平台上轮换过密钥的应用(apksigner)。

这些能力项说明旧密钥还能做什么:在由它签名的已安装数据之上安装更新、共享 UID、持有签名权限,以及(此处为 false)回滚到旧密钥。如果旧密钥已被泄露,用 apksigner lineage 撤销这些能力。

后量子:混合签名 v3.2

Android 17 新增了 v3.2,这是“一种混合签名方案,旨在支持 Android 生态系统向后量子密码过渡”:APK“同时使用 RSA 或 ECDSA 等经典算法和一种 PQC 算法”签名,初期为 ML-DSA-65 或 ML-DSA-87,签名存放在 ID 为 0x70e1c89f 的分块中,该分块“必须恰好包含两个签名者”(v3.2)。Android 17 会验证这两个签名;更早的版本会跳过该分块,改用仍须保留的 v3.0 或 v3.1 分块。平台把 PQC 密钥视为经典密钥的一次轮换,两个签名者必须具有“完全相同的先前签名历史”。

Java 25 的 keytool 可以创建 ML-DSA 密钥(-keyalg ML-DSA-65),build-tools 37.0.0 也能用这些密钥签名。我们试了好几次才成功:在两个混合签名者都有谱系之前,apksigner 一直报 NullPointerException 错误;而且 PQC 谱系必须和经典签名者的谱系一样,从同一个先前的密钥轮换而来:

混合签名:key2(经典)和 ML-DSA-65 密钥,均由 key1 轮换而来
keytool -genkeypair -keystore pq.p12 -storetype PKCS12 -storepass changeit -alias app \
  -dname "CN=Example App PQ signing key" -validity 10000 -keyalg ML-DSA-65
apksigner rotate --out lineage-pq.bin --old-signer --ks key1.p12 --ks-pass pass:changeit \
  --new-signer --ks pq.p12 --ks-pass pass:changeit
apksigner sign --ks key1.p12 --ks-pass pass:changeit --next-signer --ks key2.p12 --ks-pass pass:changeit --lineage lineage.bin \
  --next-signer --ks key2.p12 --ks-pass pass:changeit --hybrid-signer-role classical --signer-lineage lineage.bin \
  --next-signer --ks pq.p12 --ks-pass pass:changeit --hybrid-signer-role pqc --signer-lineage lineage-pq.bin \
  --hybrid-min-sdk-version 37 --out app-hybrid.apk F-Droid.apk
apksigner verify -v --print-certs app-hybrid.apk
输出(已筛选)
Verifies
Verified using v2 scheme (APK Signature Scheme v2): true
Verified using v3 scheme (APK Signature Scheme v3): true
Verified using v3.1 scheme (APK Signature Scheme v3.1): true
Verified using v3.2 scheme (APK Signature Scheme v3.2): true
V3.2 Hybrid Classical Signer: (minSdkVersion=36 (dev release=true), maxSdkVersion=2147483647) certificate DN: CN=Example App signing key 2027
V3.2 Hybrid Classical Signer: (minSdkVersion=36 (dev release=true), maxSdkVersion=2147483647) key algorithm: EC
V3.2 Hybrid PQC Signer: (minSdkVersion=36 (dev release=true), maxSdkVersion=2147483647) certificate DN: CN=Example App PQ signing key
V3.2 Hybrid PQC Signer: (minSdkVersion=36 (dev release=true), maxSdkVersion=2147483647) key algorithm: ML-DSA
V3.1 Signer: (minSdkVersion=33, maxSdkVersion=2147483647) certificate DN: CN=Example App signing key 2027
V3.0 Signer: (minSdkVersion=24, maxSdkVersion=32) certificate DN: CN=Example App signing key 2026

现在一个 APK 里有三代密钥:key1 用于 Android 7 至 12,key2 用于 Android 13 及更高版本,key2 加上 ML-DSA-65 密钥用于支持 v3.2 的平台。与单一签名的 APK 相比,混合签名的 APK 大了 20,480 字节。即使指定了 --hybrid-min-sdk-version 37,这一版 apksigner 仍把 v3.2 的范围打印为“36 (dev release=true)”,这是它对仍在开发中的平台所用的编码方式;应检查你发布时所用的 build-tools 版本的输出。大多数开发者永远不会运行这些命令:Play 会生成并管理混合签名密钥(见下一节)。

v4 与增量安装

--v4-signing-enabled true 会在 APK 旁边写入一个 .idsig 文件。文件中包含 fs-verity 格式的 APK Merkle 树以及对树根的签名,因此 adb install --incremental 可以在所有字节到达之前就启动应用。AOSP 明确指出:“v4 签名需要一个补充的 v2 或 v3 签名。”在实验中,12.6 MB 的 APK 对应的签名文件为 103,041 字节,apksigner verify --v4-signature-file app-v4.apk.idsig 报告 Verified using v4 scheme (APK Signature Scheme v4): true。v4 是面向开发和流式安装的功能,不会上传到应用商店。

Play 应用签名与开发者验证

在 Google Play 上,为用户所安装内容签名的密钥通常不归你所有。使用 Play 应用签名时,你用上传密钥(“2048 位或以上的 RSA 密钥”)为 app bundle 签名,Google 再用它保管的应用签名密钥对分发出去的 APK 重新签名。由此带来两点影响:

  • 上传密钥丢失或泄露后可以补救:“你不会被锁在应用之外”;你可以在 Play 管理中心(Play Console)用新证书申请重置上传密钥。
  • 应用签名密钥的轮换(“密钥升级”)在 Play 管理中心中操作。Play 现在会让新应用“采用量子就绪的混合签名,并使用 Google 生成的密钥”:“一个经典的 4096 位 RSA 密钥和一个后量子 ML-DSA-65 密钥”,外加一个面向旧设备的经典密钥,“因此你的应用将使用三个不同的密钥”。Play 指出,混合签名目前与 v4 不兼容;对于 Android 17 及更高版本上的安装,Play 提供每年一次的密钥升级。

在 Play 之外,签名密钥更加重要。Android 的开发者验证会为安装在经过认证的设备上的应用,将开发者及其包名与签名密钥一起注册:相关保护措施已于2026年9月30日在巴西、印度尼西亚、新加坡和泰国启动,Google 计划在2027年把验证推广到全球。其常见问题解答说得很直白:“如果你丢失了签名密钥,就无法注册你的软件包。”

常见错误

  • 签名后修改 APK:使用 apksigner 时,“必须在为 APK 文件签名之前使用 zipalign”(zipalign);任何会重写 ZIP 的工具都是如此。签名后的任何改动都会导致上文的摘要不匹配。
  • 轻信“Verifies”:任何密钥都能产生有效的签名。应把签名者证书的 SHA-256 摘要与你预期的值比对(Play 管理中心会显示你的摘要;F-Droid 和许多开源项目会公布它们的摘要)。
  • 无故在 Android 12 及更早版本上用 v3 轮换:应让 apksigner 以 v3.1 为目标。
  • 签名密钥只有一份:对于 Play 之外的应用,没有重置这回事——密钥就是应用本身。
  • 把密钥放进仓库:应把上传密钥保存在硬件支持的密钥库或云端密钥库中,只给 CI 签名权限,而不是密钥文件。
  • 在后端固定应用签名证书(API 许可名单、OAuth 客户端注册、按证书限制的 Firebase 或 Maps 密钥),却没有为密钥升级做规划:Play 文档要求你向 API 提供方注册新的指纹,采用混合签名时是两个新密钥的指纹。

参考资料