bitchat-android/docs/reproducible-builds.md

274 lines
11 KiB
Markdown

# Reproducible builds
Bitchat's canonical phone-app release build produces byte-for-byte reproducible
unsigned APKs and an unsigned Android App Bundle (AAB). CI builds the release
twice in independent jobs and exposes a verified release artifact only when
every canonical byte matches. The `:wear` module is tested and
dependency-locked but is outside this phone artifact contract until a separate
Wear release and signing process is defined.
Signing remains local: no keystore or signing password is stored in or exposed
to GitHub Actions. Anyone can reproduce the unsigned artifacts; maintainers
download the verified CI output, sign the selected GitHub APKs and Play upload
AAB locally, then manually publish those exact files.
Maintainers should use the complete
[Android maintainer release guide](maintainer-release-guide.md) for the
step-by-step release procedure, artifact inventory, local signing commands, and
GitHub/Google Play publication checklist.
## What is pinned
- Gradle wrapper version and distribution SHA-256
- dependency versions, strict Gradle dependency locks, and downloaded-artifact
SHA-256 verification metadata
- exact Temurin JDK release and digest-pinned Linux builder image
- Android platform, Platform Tools, and Build Tools archives by filename and
SHA-256, plus the accepted SDK license-text SHA-1 required to use them
- Kotlin/JVM toolchain and bytecode target
- Arti source tag and full commit, stable native build epoch, Rust, `cargo-ndk`,
Android NDK, Cargo lockfile, digest-pinned Rust builder image, and immutable
Debian package snapshot
- immutable full commit SHAs for every third-party GitHub Action
The build uses a clean source tree, an isolated Gradle user home, UTC, a stable
locale, `SOURCE_DATE_EPOCH` from the Git commit, no Gradle build or configuration
cache, fresh tasks, and a non-incremental in-process Kotlin compiler. Native
builds remap source paths and release validation rejects host paths in packaged
libraries. The container overlays a canonical `local.properties`, so an ignored
Android Studio file cannot redirect Gradle to a host-specific SDK.
AGP's embedded VCS record is disabled because its Git discovery depends on the
host checkout layout. The canonical `BUILDINFO.json` and GitHub provenance
attestation record the host-verified commit instead.
The authoritative pins are:
- `gradle/wrapper/gradle-wrapper.properties`
- `gradle/libs.versions.toml`
- `settings-gradle.lockfile`
- `app/gradle.lockfile`
- `wear/gradle.lockfile`
- `gradle/verification-metadata.xml`
- `tools/reproducible-builds/TOOLCHAIN.env`
- `tools/arti-build/TOOLCHAIN.env`
- `tools/arti-build/Cargo.lock`
## Reproduce a release locally
Requirements are Git, Docker with Linux/amd64 support, and enough free space for
the Android and Gradle images and dependencies.
```bash
git clone https://github.com/permissionlesstech/bitchat-android.git
cd bitchat-android
git checkout vX.Y.Z
tools/reproducible-builds/build-in-container.sh \
.reproducible-build/local-vX.Y.Z
```
The output contains:
- unsigned APKs for arm64, armv7, x86, x86_64, and universal installs
- `bitchat-android-release-unsigned.aab`
- `BUILDINFO.json`
- `SHA256SUMS.unsigned`
The output directory must not already contain files. The script rejects a dirty
checkout so the commit in `BUILDINFO.json` identifies all source inputs.
To test reproducibility yourself, build into two empty directories and compare:
```bash
BITCHAT_CONTAINER_GRADLE_HOME_NAME=gradle-home-first \
tools/reproducible-builds/build-in-container.sh .reproducible-build/first
BITCHAT_CONTAINER_GRADLE_HOME_NAME=gradle-home-second \
tools/reproducible-builds/build-in-container.sh .reproducible-build/second
tools/reproducible-builds/compare-release.sh \
.reproducible-build/first \
.reproducible-build/second
```
If a comparison fails and `diffoscope` is installed, the comparison script
automatically reports the first differing artifact.
## Verify a GitHub release
Install the GitHub CLI, authenticate it if necessary, check out the release tag,
and run:
```bash
git checkout vX.Y.Z
tools/reproducible-builds/verify-github-release.sh vX.Y.Z
```
That command:
1. downloads all release APKs, the AAB, build information, and checksum files;
2. verifies the canonical unsigned build's GitHub artifact-attestation subjects
against this repository;
3. verifies `BITCHAT_SHA256SUMS`;
4. checks that the local source commit is the release commit;
5. rebuilds in the pinned container; and
6. byte-compares every unsigned APK, the unsigned AAB, build information, and
the unsigned checksum manifest.
To verify the published checksums and attestations without rebuilding:
```bash
tools/reproducible-builds/verify-github-release.sh vX.Y.Z --no-rebuild
```
For a manual signature check, use the exact `apksigner` from Android Build Tools
37.0.0:
```bash
apksigner verify --verbose --print-certs bitchat-android-universal.apk
```
Compare the reported signer certificate SHA-256 with
`BITCHAT_GITHUB_RELEASE_CERT_SHA256` in `gradle.properties`. A matching
certificate proves who signed the APK; the checksum, attestation, and local
unsigned rebuild establish which source and build produced it. A third party
cannot recreate the signed bytes without the private release key.
You can also prove that the signed APK contains the same archive entries and
uncompressed payload bytes as the reproduced unsigned APK:
```bash
tools/reproducible-builds/compare-archive-payloads.sh \
.reproducible-build/local-vX.Y.Z/bitchat-android-universal-unsigned.apk \
bitchat-android-universal.apk
```
GitHub's manual equivalents are:
```bash
gh release download vX.Y.Z
sha256sum -c BITCHAT_SHA256SUMS
gh attestation verify bitchat-android-universal-unsigned.apk \
--repo permissionlesstech/bitchat-android
```
## Verify a Google Play release
Google Play App Signing changes the verification boundary:
- maintainers upload a signed AAB using the upload key;
- Google Play generates optimized, device-specific APK splits from that AAB;
- Google signs the delivered APKs with the app-signing key.
Consequently, a Play-delivered APK is not expected to be byte-identical to the
GitHub universal APK or to a locally built APK. Use this procedure instead:
1. In Play Console, open **Test and release > App bundle explorer**, select the
release/version code, and download the original app bundle if that option is
available to your account. Compare its unsigned payload with the reproduced
`bitchat-android-release-unsigned.aab`:
```bash
tools/reproducible-builds/compare-archive-payloads.sh \
.reproducible-build/local-vX.Y.Z/bitchat-android-release-unsigned.aab \
downloaded-from-play.aab
```
The helper excludes JAR-signing metadata and compares every other entry name
and uncompressed byte.
2. In **Setup > App integrity**, record the SHA-256 fingerprint under **App
signing key certificate**. This is different from the upload-key certificate
and may be different from the GitHub release certificate.
3. In App bundle explorer, download the Play-generated universal APK or the APKs
for a representative device. Verify each APK:
```bash
apksigner verify --verbose --print-certs downloaded-from-play.apk
```
The signer SHA-256 must equal the Play Console app-signing certificate.
4. Confirm package name `com.bitchat.droid`, version code, version name, and
manifest/security configuration with Android's `apkanalyzer` or `aapt2`.
5. Recreate Google's split-generation behavior from the reproduced AAB with the
same `bundletool` version and a saved device specification:
```bash
bundletool build-apks \
--bundle=bitchat-android-release-unsigned.aab \
--output=local.apks \
--device-spec=device.json
```
This last check validates bundle-to-APK behavior, but it is not a byte-equality
claim: Play's server-side `bundletool` version, optimization, and signing inputs
are controlled by Google. The strongest public Play verification requires
maintainers to retain the uploaded AAB, publish its digest and provenance, and
record the Play version code and app-signing certificate fingerprint alongside
the release.
The GitHub workflow builds and attests the canonical unsigned AAB. A maintainer
locally creates `bitchat-android-play-upload.aab` from that exact file and
uploads it manually to Google Play.
## Maintainer release process
Follow the
[Android maintainer release guide](maintainer-release-guide.md). It is the
authoritative operational runbook from version preparation through the signed
tag, GitHub Actions artifact download, local APK/AAB signing, GitHub draft,
Play internal test, public rollout, and post-release verification.
The workflow has no signing secrets and never publishes a release by itself.
## Updating dependencies or toolchains
Dependency changes must update and review both the lock state and verification
metadata:
```bash
./gradlew testDebugUnitTest lintDebug resolveIdeRuntimeClasspathCopyLocks \
--write-locks \
--write-verification-metadata sha256
```
`resolveIdeRuntimeClasspathCopyLocks` records the transient runtime classpath
copies that Android Studio resolves during model import. Their selected versions
are persisted under the generated copy configuration names while the canonical
debug and release runtime classpaths remain strictly locked.
Generate release lock entries in separate invocations because split APK and AAB
intermediates cannot coexist:
```bash
./gradlew :app:clean :app:bundleRelease \
--write-locks \
--write-verification-metadata sha256
./gradlew :app:clean :app:assembleRelease \
--write-locks \
--write-verification-metadata sha256
```
Review every new repository, component, artifact name, version, and checksum.
Do not accept verification metadata generated after an unexplained checksum
failure.
The verification metadata deliberately trusts only IDE documentation and source
attachments (`*-javadoc.jar`, `*-sources.jar`, and Gradle's `*-src.zip`). Android
Studio resolves these outside the build dependency graph, and they are not build
inputs. Compiled artifacts and dependency metadata remain checksum-verified.
When changing Gradle, update the wrapper and independently verify the new
distribution SHA-256. When changing JDK or Android tools, update the exact
version, archive checksum, and base-image digest together. Native updates follow
[`tools/arti-build/README.md`](../tools/arti-build/README.md).
## References
- [Gradle dependency locking](https://docs.gradle.org/current/userguide/dependency_locking.html)
- [Gradle dependency verification](https://docs.gradle.org/current/userguide/dependency_verification.html)
- [Gradle wrapper checksum verification](https://docs.gradle.org/current/userguide/best_practices_security.html#use_the_gradle_wrapper_and_verify_the_wrapper_checksum)
- [GitHub Actions security hardening](https://docs.github.com/en/actions/reference/security/secure-use)
- [GitHub artifact attestation verification](https://docs.github.com/en/actions/how-tos/secure-your-work/use-artifact-attestations/use-artifact-attestations)
- [Google Play App Signing](https://support.google.com/googleplay/android-developer/answer/9842756)
- [Play Console App bundle explorer](https://support.google.com/googleplay/android-developer/answer/9859152)
- [Android `bundletool`](https://developer.android.com/tools/bundletool)
- [Android `apksigner`](https://developer.android.com/tools/apksigner)