可重复构建

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添加两个数据元素platformBuildVersionNameplatformBuildVersionCodeplatformBuildVersionName 包括 platforms 包的”修订版”,根据该包构建(例如:android-23),然而同一 platforms 包的不同”修订版”不能并行安装。 另外,SDK 工具不支持指定所需的修订作为构建过程的一部分。 这往往会导致另一种可重复构建,它和真正可重复构建之间唯一的区别是 platformsBuildVersionName 属性。

_platform_是 Android SDK 的一部分,代表安装在手机上的标准库。 它们的版本有两部分:“版本代码”,它是一个整数,代表 SDK 版本,以及“修订版”,它代表每个平台的错误修复版本。 这些版本可以在包含的 build.prop 文件中看到。 每个修订版在 ro.build.version.incremental 中有不同的编号。 Gradle 无法在 compileSdkVersiontargetSdkVersion 中指定修订版本。一次只能安装一个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 singleCrunchpngcrushzopflipngoptipng。这些不提供确定性的输出,关于原因仍然是一个悬而未决的问题。由于 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/ 子文件夹名称)

来源