F-Droid 支持应用的可重复构建,因此任何人都可以再次运行构建过程,并再现与原始版本相同的 APK。 这意味着 F-Droid 可以验证一个应用是 100% 的自由软件,同时仍然使用原始开发者的 APK 签名。 F-Droid 使用 APK 签名复制来验证可重复构建。
这个概念偶尔也被称为”确定性构建”。 这是一个更严格的标准:这意味着整个过程每次都以相同的顺序运行。 最重要的是,任何人都可以运行这个过程,最后得到完全相同的结果。
截至目前,它是如何实施的
在验证它们与使用 fdroiddata
配方构建的二进制文件匹配后,现在可以从其他地方(例如上游开发人员)发布已签名的二进制文件(APK)。只有在适当匹配的情况下才会发布。这一流程作为
fdroid publish
的一部分实现的。发布阶段的可重现性检查遵循以下逻辑:
同时发布(上游)开发者签名和 F-Droid 签名的 APK
这种方法允许同时发布(上游)开发者签名和 F-Droid 签名的 APK。这使我们能够为从 F-Droid 以外的其他来源(例如 Play Store)安装应用的用户发送更新,同时也为由 F-Droid 构建和签名的应用发送更新。
这需要提取并向 fdroiddata 添加(上游)开发人员签名。然后将这些签名复制到从 fdroiddata 配方构建的 unsigned APK。我们提供了一个命令,可以轻松地从 APK 中提取签名:
$ cd /path/to/fdroiddata
$ fdroid signatures F-Droid.apk
除了本地文件,你还可以向 fdroid 签名
提供 HTTPS 网址。
签名文件会被提取到应用的元数据目录中,准备用 fdroid publish
使用。一个签名由 2-6 个文件组成:一个 v1
签名(清单、签名文件和签名块文件)和/或一个 v2/v3 签名(APK 签名块和偏移量);如果使用 signflinger
而不是
apksigner
对 APK 进行 v1 签名,会有一个 differences.json
文件。提取这样一个文件的结果将类似于这些文件列表:
$ ls metadata/org.fdroid.fdroid/signatures/1000012/ # v1 signature only
CIARANG.RSA CIARANG.SF MANIFEST.MF
$ ls metadata/your.app/signatures/42/ # v1 + v2/v3 signature
APKSigningBlock APKSigningBlockOffset MANIFEST.MF YOURKEY.RSA YOURKEY.SF
如果你不想安装 fdroidserver
(或者有一个不支持提取 v2/v3 签名的旧版本),你也可以使用
apksigcopier
(例如在 Debian、
Ubuntu、Arch Linux、NixOS 中可用)而不是 fdroid signatures
:
$ cd /path/to/fdroiddata
$ APPID=your.app VERSIONCODE=42
$ mkdir metadata/$APPID/signatures/$VERSIONCODE
$ apksigcopier extract --v1-only=auto Your.apk metadata/$APPID/signatures/$VERSIONCODE
只发布(上游)开发者签名的 APK
对这种方法,元数据中的一切都应该和正常的一样,加上 Binaries:
指令来指定从哪里获得二进制文件(APKs)。在这种情况下,F-Droid
不会试图发布由 F-Droid 签名的 APK。如果 fdroid publish
能够验证可下载的 APK 匹配从 fdroiddata
配方构建的 APK,那么可下载的 APK 将被发布。否则 F-Droid 将跳过发布该应用的这个版本。
以下是 Binaries
指令的示例:
二进制文件:https://example.com/path/to/myapp-%v.apk
另见:Build Metadata Reference - Binaries
可重复的签名
F-Droid 使用 APK 签名(嵌入式签名的一种形式)来验证可重复构建,这需要将签名从一个已签名的 APK 复制到一个未签名的 APK,然后检查后者是否验证。 旧的 v1 (JAR) 签名只包括 APK 的 内容(例如,与 ZIP 元数据和排序无关),但 V2/V3 签名包括 APK 中所有其他字节。 因此,APK 必须在签名 之前 和 之后 完全相同(除了签名之外)才能正确验证。
复制签名使用的算法与 apksigner
签署 APK 时使用的算法相同。 因此,重要的是,(上游)开发人员在签署 APK
时也要这样做,最好是使用 apksigner
。
验证构建
许多人或组织对可重建构建感兴趣,以确保 f-droid.org 构建与原始源匹配,并且没有更改任何内容。在这种情况下,不会发布生成的 APK 以供安装。 验证服务器 可以自动完成这个过程。
可重复构建
由于 Java 代码经常被各种不同的 Java 版本编译成相同的字节码,因此不少构建工作已经无需额外努力即可验证。 Android SDK 的
build-tools
会在生成的 XML、PNG 等文件中产生差异,但这通常不是问题,因为 build.gradle
包括要使用的确切版本的
build-tools
。
任何用 NDK 构建的东西都会更敏感。 例如,即使是使用完全相同的NDK版本(例如 r13b
),但在不同的平台上(例如 macOS 与
Ubuntu),所生成的二进制文件也会有差异。
此外,我们还必须注意任何对排序敏感的东西,包括时间戳或构建路径等。
Google 也在努力实现 Android 应用的可重复构建,所以使用最新版本的 Android SDK 是有帮助的。 一个具体情况是,从 Gradle Android 插件 v2.2.2 开始,APK 文件的 ZIP 元数据中的时间戳被自动置零了。
可重复 APK 工具
如果消除造成差异的原因难以实现,来自
reproducible-apk-tools
的脚本(在 fdroiddata 中作为 srclib
可用)可能有助于使构建可重复,例如,通过固定换行 (CRLF vs LF) 或使 ZIP
顺序确定。根据具体情况,上游开发者需要在签署 APK 之前使用这些脚本,或者根据 fdroiddata 配方使用,或者两者同时使用。
最初创建 disorderfs
的目的是在构建过程中插入非确定性,但也可以出于相反目的使用它:使得从文件系统的读取具有确定性。在某些情况下,这可以使 resources.arsc
可重复。下面是一个来自现有配方的例子:
$ mv my.app my.app_underlying
$ disorderfs --sort-dirents=yes --reverse-dirents=no my.app_underlying my.app
不可重复构建的可能原因
构建不可重复的方式有很多种。有些问题相对容易避免,有些很难解决。我们试图在下面列出一些常见的原因。
另见 这个 gitlab issue。
Bug: Android Studio 构建有非确定性 ZIP 顺序
APK 文件中非决定性的 ZIP 项顺序造成构建不可重复(可能需要 Google 账户方可查看)。
注:该问题在 7.1.X
及更新版本的 Android Gradle 插件 (com.android.tools.build:gradle
/
com.android.application
) 中应该已被修复。
在用 Android Studio 构建 APK 文件时,APK 中 ZIP 项目的顺序可能不同于直接调用 gradle
进行构建的
APK,这会影响可重现性;顺序可能是完全非确定性的,甚至在相同源码的不同构建之间也不一样。
旧版本的一个变通办法是直接调用 gradle
(像在 F-Droid 或 CI 构建期间一样),来绕过 Android Studio:
$ ./gradlew assembleRelease
请注意:取决于你的签名配置,可能需要之后用 apksigner
对 APK 进行签名,因为在这种情况下 APK 签名不是由 Android
Studio 执行的。
Bug: baseline.profm not deterministic
Non-stable
assets/dexopt/baseline.profm
(可能需要 Google 账号才能查看)。
另见 这篇变通方法的文章。
Bug: coreLibraryDesugaring not deterministic
注:该问题在 3.0.69
及更新版本的 R8 (com.android.tools:r8
)中应该已被修复。
在某些情况下,由于 coreLibraryDesugaring
中的错误,构建不可重复(可能需要 Google
帐号才能查看);这曾影响 NewPipe。
Bug:Windows 和 Linux 版本间的行结束符差异
Windows 和 Linux 系统上进行构建的换行符差异造成构建不可重复(可能需要 Google 账户才能查看)。
一个变通方法是在行结束符“错误”的未签名 APK 文件上运行
fix-newlines.py
将他们从 LF 更改为 CRLF (或者使用 --from-crlf
反向操作)并在之后再次对它进行 zipalign
。
并发:可重现性可以取决于CPU/核心的数目
这可能影响 .dex
文件(虽然这似乎比较少见)或本机代码(如 Rust)。
只使用 1 个 CPU/核心作为变通办法:
export CPUS_MAX=1
export CPUS=$(getconf _NPROCESSORS_ONLN)
for (( c=$CPUS_MAX; c<$CPUS; c++ )) ; do echo 0 > /sys/devices/system/cpu/cpu$c/online; done
请注意:这种变通方法影响整台机器,因此推荐在非持久性的虚拟机或容器中使用它。
对于 Rust 代码,你可以设置 codegen-units =
1
。
另见 这个 gitlab issue。
嵌入的构建路径
嵌入的构建路径是可重复性问题的一个来源,影响使用
Flutter、python-for-android 或原生代码(如 Rust、C/C++、各种 libfoo.so
)构建的应用。完全用 Java
和/或 Kotlin 编写的应用一般不会受影响。
通常来说,最简单的解决方案是在构建时始终使用相同的工作目录;如,/builds/fdroid/fdroiddata/build/your.app.id
(F-Droid CI), /home/vagrant/build/your.app.id
(F-Droid build server), 或
/tmp/build
。
注:使用全局可写的 tmp
的子目录可能会有安全影响(在多用户系统上)。
内嵌的时间戳
内嵌的时间戳是可重复性问题最常见的来源,最好避免。
AboutLibraries Gradle 插件
要避免这个插件 (com.mikepenz.aboutlibraries.plugin
) 添加时间戳到它生成的 JSON 文件,你可以添加这个到
build.gradle
:
aboutLibraries {
// Remove the "generated" timestamp to allow for reproducible builds
excludeFields = ["generated"]
}
对于 build.gradle.kts
,请添加这个:
aboutLibraries {
// 移除 "generated" 时间戳以允许可重复构建
excludeFields = arrayOf("generated")
}
本地库的剥离
似乎剥离原生库,例如 libfoo.so
,可能会导致间歇性重现性问题。重建时使用确切的 NDK 版本很重要,例如
r21e
。禁用剥离有时会有所帮助。 Gradle 似乎默认剥离共享库,甚至应用也通过 AAR 库接收共享库。以下是在 Gradle 中禁用它的方法:
android {
packagingOptions {
doNotStrip '**/*.so'
}
}
NDK build-id
在不同的构建机器上,使用不同的 NDK 路径和不同的项目路径(及其 jni
目录)。
这导致调试符号中源文件的路径不同,造成该链接器生成不同的_build-id_,剥离后保留。
一个可能的解决方案是传递 --build-id=none
到链接器,这会彻底禁止生成 build-id 。
NDK 哈希样式
LLVM
在不同平台上传递给链接器的默认值也是不同的。在此提交
被合并入 NDK 后, --hash-style=gnu
默认用于 Debian。要更改哈希样式,可以传递
--hash-style=gnu
到链接器。
platform 修订版
Android SDK
工具在2014年改为在构建过程中在
AndroidManifest.xml
中
添加两个数据元素:platformBuildVersionName
和 platformBuildVersionCode
。platformBuildVersionName
包括 platforms
包的”修订版”,根据该包构建(例如:android-23
),然而同一 platforms 包的不同”修订版”不能并行安装。 另外,SDK
工具不支持指定所需的修订作为构建过程的一部分。 这往往会导致另一种可重复构建,它和真正可重复构建之间唯一的区别是
platformsBuildVersionName
属性。
_platform_是 Android SDK 的一部分,代表安装在手机上的标准库。 它们的版本有两部分:“版本代码”,它是一个整数,代表 SDK
版本,以及“修订版”,它代表每个平台的错误修复版本。 这些版本可以在包含的 build.prop
文件中看到。 每个修订版在
ro.build.version.incremental
中有不同的编号。 Gradle 无法在 compileSdkVersion
或
targetSdkVersion
中指定修订版本。一次只能安装一个platform-23
,不像
build-tools
,每个版本都可以并行安装。
这里有两个例子,其中所有的差异都涉嫌来自于平台的不同修订:
- https://verification.f-droid.org/de.nico.asura_12.apk.diffoscope.html
- https://verification.f-droid.org/de.nico.ha_manager_25.apk.diffoscope.html
PNG 优化/压缩
Android 构建过程的一个标准部分是运行某种 PNG 优化工具,例如 aapt
singleCrunch
、pngcrush
、zopflipng
或
optipng
。这些不提供确定性的输出,关于原因仍然是一个悬而未决的问题。由于 PNG 通常提交到源存储库,因此解决此问题的方法是在 PNG
文件上运行你选择的工具,然后将这些更改提交到源存储库(例如 git
)。然后,通过将其添加到 build.gradle
来禁用默认的 PNG
优化过程:
android {
aaptOptions {
cruncherEnabled = false
}
}
请注意,svgo
等工具可以对 SVG 文件进行类似的优化。
生成自矢量可绘制对象的 PNG 图片
Android Gradle 插件为旧 Android 版本从矢量可绘制图形生成 PNG 资源。不幸的是,生成的 PNG 文件不可重复。
你可以通过添加这个到 build.gradle
来禁止生成 PNG:
android {
defaultConfig {
vectorDrawables.generatedDensities = []
}
}
R8 优化器
似乎某些 R8 优化以不确定的方式完成,在不同的构建运行中产生不同的字节码。
例如,R8 尝试优化 ServiceLoader
的使用,在代码中制作所有服务的静态列表。每次构建运行时,此列表的顺序可能不同(甚至不完整)。避免这种行为的唯一方法是禁用在
proguard-rules.pro
中声明优化类的优化:
-keep class kotlinx.coroutines.CoroutineExceptionHandler
-keep class kotlinx.coroutines.internal.MainDispatcherFactory
使用 R8 要小心。始终多次测试你的构建,并禁用产生非确定性输出的优化。
资源压缩器
可以通过从包中删除未使用的资源来减小 APK 文件的大小。当项目依赖于一些臃肿的库(例如 AppCompat)时,这很有用,尤其是在使用 R8/ProGuard 代码压缩时。
然而,在不同的平台上,资源收缩器可能会增加 APK 的大小,尤其是在没有许多资源需要压缩的情况下,在这种情况下,将使用原始的 APK 而不是收缩后的 APK(Gradle 插件的非确定性行为)。避免使用资源压缩器,除非它能显著减少 APK 文件的大小。
ZIP 元数据
APKs 使用 ZIP 文件格式,ZIP 格式最初是围绕 MSDOS 的 FAT 文件系统设计的。 UNIX 文件权限是作为一个扩展添加的。 APK 只需要最基本的 ZIP 格式,没有任何的扩展。 在最后的发布签名过程中,这些扩展往往被剥离出来。 但 APK 构建过程中可以添加它们。例如:
--- a2dp.Vol_137.apk
+++ sigcp_a2dp.Vol_137.apk
@@ -1,50 +1,50 @@
--rw---- 2.0 fat 8976 bX defN 79-Nov-30 00:00 AndroidManifest.xml
--rw---- 2.0 fat 1958312 bX defN 79-Nov-30 00:00 classes.dex
--rw---- 1.0 fat 78984 bx stor 79-Nov-30 00:00 resources.arsc
+-rw-rw-rw- 2.3 unx 8976 b- defN 80-000-00 00:00 AndroidManifest.xml
+-rw---- 2.4 fat 1958312 b- defN 80-000-00 00:00 classes.dex
+-rw-rw-rw- 2.3 unx 78984 b- stor 80-000-00 00:00 resources.arsc
不匹配的 Java 版本
有时候会有不匹配的情况,因为上游可能使用不同的版本(如 Gradle 8 使用 Java 17),需要更新配方。
APK diff 会存在类似这样的条目,如 Java 17 vs Java 11:
- .annotation system Ldalvik/annotation/Signature;
- value = {
- "()V"
- }
- .end annotation
特定于编程语言的操作指南
原生库可能由各种工具和语言所构建。虽然它们在可重复构建方面遇到的问题差不多,但修复方法却不同。下面列举一些已知的解决方案:
ndk-build
LOCAL_LDFLAGS += -Wl,<linker args>
可被添加到 Android.mk
文件或
build.gradle
/build.gradle.kts
:
android {
defaultConfig {
externalNativeBuild {
ndkBuild {
arguments "LOCAL_LDFLAGS += -Wl,<linker args>"
}
}
}
}
CMake
对于 3.13 起的 CMake 版本,可以全局添加add_link_options(LINKER:<linker args>)
到 CMakeLists.txt
。 对于 3.13 之前的 CMake 版本,
可以对每个目标使用
target_link_libraries(<target> LINKER:<linker args>)
。
Golang
链接器参数可被添加到 CGO_LDFLAGS
。一些其他可被传递到 go build
的有用参数是 -ldflags="-buildid="
、
-trimpath
(避免内嵌的构建路径)和 -buildvcs=false
。
Rust
编译器和链接器参数可被添加到 build.rustflags
。
添加链接器参数可带link-args=-Wl,<linker args>
; --remap-path-prefix=<old>=<new>
可被添加到 strip build 路径。
迁移到可重复构建
TODO
- APK 的 jar 排序顺序
aapt
版本产生不同的结果(XML 和 res/ 子文件夹名称)
来源
- https://gitlab.com/fdroid/fdroidserver/commit/8568805866dadbdcc6c07449ca6b84b80d0ab03c
- 验证服务器
- https://verification.f-droid.org
- https://reproducible-builds.org
- https://wiki.debian.org/ReproducibleBuilds
- https://gitian.org/
- Google Issue #70292819 platform-27_r01.zip 被新更新覆盖(需要 Google 账号登录以及 JavaScript)
- Google Issue #37132313 platformBuildVersionName 使构建难以重现,产生不必要的差异(需要 Google 账号登录以及 JavaScript)
- Google Issue #110237303 使用非确定性构建的 resources.arsc,阻止了可重复的 APK 构建(需要 Google 账号登录以及 JavaScript)
- 由 navigation.safeargs.kotlin 生成的不可重复/不确定的代码 (需要 Google 账号登录以及 JavaScript)
- 基于构建过程中所用 CPU 数量的不必要的 DEX 代码差异 (需要 Google 账户登录和开启 JavaScript)