Skip to content

Commit 1e74739

Browse files
committed
Reproducible release builds
1 parent a38baeb commit 1e74739

7 files changed

Lines changed: 181 additions & 33 deletions

File tree

.dockerignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
build/
2+
app/build/
3+
.gradle/
4+
.idea/
5+
*.iml

.gitattributes

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Normalize text files to LF in the repository
2+
* text=auto
3+
4+
# Shell scripts must use LF (they run inside the Linux container)
5+
gradlew text eol=lf
6+
*.sh text eol=lf
7+
8+
# Windows scripts stay CRLF
9+
*.bat text eol=crlf
10+
11+
# Binary files: never alter line endings
12+
*.so binary
13+
*.jar binary
14+
*.keystore binary
15+
*.jks binary
16+
*.png binary
17+
*.webp binary

Dockerfile

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
FROM --platform=linux/amd64 eclipse-temurin:17-jdk-jammy@sha256:beabb759e6f9653c843958d1d1f5cecb881dfb85aa6081e2bef099ab1260344e
2+
ENV ANDROID_SDK_ROOT=/opt/android-sdk ANDROID_HOME=/opt/android-sdk DEBIAN_FRONTEND=noninteractive
3+
RUN apt-get update && apt-get install -y --no-install-recommends \
4+
unzip wget ca-certificates && rm -rf /var/lib/apt/lists/*
5+
ARG CMDLINE_TOOLS=11076708
6+
RUN mkdir -p ${ANDROID_SDK_ROOT}/cmdline-tools && \
7+
wget -q https://dl.google.com/android/repository/commandlinetools-linux-${CMDLINE_TOOLS}_latest.zip -O /tmp/ct.zip && \
8+
unzip -q /tmp/ct.zip -d ${ANDROID_SDK_ROOT}/cmdline-tools && \
9+
mv ${ANDROID_SDK_ROOT}/cmdline-tools/cmdline-tools ${ANDROID_SDK_ROOT}/cmdline-tools/latest && rm /tmp/ct.zip
10+
ENV PATH=${PATH}:${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin:${ANDROID_SDK_ROOT}/platform-tools
11+
RUN yes | sdkmanager --licenses >/dev/null && sdkmanager \
12+
"platform-tools" "platforms;android-36" "build-tools;35.0.0" \
13+
"ndk;27.0.12077973" "cmake;3.22.1"
14+
ENV GRADLE_USER_HOME=/opt/gradle-home
15+
COPY gradle/wrapper /opt/gradlew-probe/gradle/wrapper
16+
COPY gradlew /opt/gradlew-probe/gradlew
17+
RUN cd /opt/gradlew-probe && chmod +x gradlew && ./gradlew --version --no-daemon && rm -rf /opt/gradlew-probe
18+
19+
WORKDIR /src

README.md

Lines changed: 5 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -17,24 +17,11 @@ See **[monolith-sec.com](https://monolith-sec.com/#downloads)**.
1717

1818
## Build from Source
1919

20-
### Requirements
20+
The published APK is **reproducible** — build it in a pinned environment and verify it
21+
byte-for-byte against this source: see **[REPRODUCIBLE.md](REPRODUCIBLE.md)**
22+
(lists the exact pinned toolchain).
2123

22-
- Android SDK with compile SDK 36, min SDK 33
23-
- NDK 27.0.12077973 (LTS)
24-
- CMake 3.22.1
25-
- Target ABI: `arm64-v8a` only
26-
- Device with TEE or StrongBox hardware-backed key storage
27-
28-
### Commands
29-
30-
```bash
31-
./gradlew assembleDebug # Build debug APK
32-
./gradlew assembleRelease # Build release APK
33-
./gradlew installDebug # Install on connected device
34-
./gradlew test # Run local unit tests
35-
./gradlew connectedAndroidTest # Run instrumented tests (device required)
36-
./gradlew detekt # Static analysis
37-
```
24+
Quick local build for contributors: `./gradlew assembleRelease` (arm64-v8a, Android 13+).
3825

3926
---
4027

@@ -59,17 +46,7 @@ If you discover a security vulnerability, please follow the procedure described
5946

6047
## License
6148

62-
Copyright © 2026 Aron Basalt
63-
64-
This program is free software: you can redistribute it and/or modify it
65-
under the terms of the GNU General Public License, version 3, as published
66-
by the Free Software Foundation.
67-
68-
This program is distributed in the hope that it will be useful, but WITHOUT
69-
ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
70-
FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
71-
72-
See the [LICENSE](LICENSE) file for the full text.
49+
GNU GPL v3 (GPL-3.0-only) · © 2026 Aron Basalt · see [LICENSE](LICENSE).
7350

7451
### Third-Party
7552

REPRODUCIBLE.md

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
# Reproducible Builds
2+
3+
Monolith's release APK is built reproducibly: anyone can rebuild the apk from the
4+
public source code inside a pinned build environment and confirm, **bit-for-bit** via hash comparison, that
5+
the published APK contains exactly the code provided on Github.
6+
7+
8+
Two different SHA-256 values are involved:
9+
10+
| Hash | Of what | Answers |
11+
|------|---------|---------|
12+
| Download hash | the **signed** APK on the website | "Do I have the same APK the developer published?"|
13+
| Reproducible hash| the **unsigned** APK you rebuild here | "Prove that it was built from the Github source code"|
14+
15+
## Trust roots (pinned for this release)
16+
17+
| Item | Value |
18+
|------|-------|
19+
| Source | this repository |
20+
| Build image | `aronbasalt/monolith-build@sha256:2c3cb23203e54f5f83cc30dfb479a49e679a8bc27c431f176d392ea3704e8dee` |
21+
| Expected unsigned APK SHA-256 | `245dcfd1d02b0c94ccbd0aebac72327119cdc007cbc17b464a274b6e2909111c` |
22+
23+
The build image is identified **by digest**, not by tag: a tag can be re-pointed,
24+
a digest is the content's fingerprint and cannot be swapped. It freezes the whole
25+
toolchain (JDK 17, Android build-tools 35.0.0, NDK 27.0.12077973, CMake 3.22.1,
26+
Gradle 8.13).
27+
28+
## Prerequisites
29+
30+
- Docker (the image targets `linux/amd64`; on Apple Silicon it runs via emulation)
31+
- Internet connection
32+
33+
## Step 1: Get the source code
34+
35+
Download and unpack the source code from Github
36+
37+
## Step 2: Pull the pinned build image by digest
38+
39+
```bash
40+
docker pull aronbasalt/monolith-build@sha256:2c3cb23203e54f5f83cc30dfb479a49e679a8bc27c431f176d392ea3704e8dee
41+
```
42+
43+
## Step 3: Build the APK
44+
45+
Run from the repository root (the folder that contains `gradlew`). Mount the source
46+
at **exactly** `/src`.
47+
48+
Linux / macOS:
49+
```bash
50+
docker run --rm --platform=linux/amd64 -v "$(pwd):/src" \
51+
aronbasalt/monolith-build@sha256:2c3cb23203e54f5f83cc30dfb479a49e679a8bc27c431f176d392ea3704e8dee \
52+
bash -lc "./gradlew --no-daemon clean assembleRelease && sha256sum app/build/outputs/apk/release/app-release-unsigned.apk"
53+
```
54+
55+
Windows (PowerShell):
56+
```powershell
57+
docker run --rm --platform=linux/amd64 -v "${PWD}:/src" `
58+
aronbasalt/monolith-build@sha256:2c3cb23203e54f5f83cc30dfb479a49e679a8bc27c431f176d392ea3704e8dee `
59+
bash -lc "./gradlew --no-daemon clean assembleRelease && sha256sum app/build/outputs/apk/release/app-release-unsigned.apk"
60+
```
61+
62+
## Step 4: Check the reproducible hash
63+
64+
The printed SHA-256 must equal:
65+
```
66+
245dcfd1d02b0c94ccbd0aebac72327119cdc007cbc17b464a274b6e2909111c
67+
```
68+
A match means your independent build is bit-for-bit identical to the reference
69+
unsigned APK.
70+
71+
## Step 5: Compare against the published (signed) APK
72+
73+
The public APK differs from your rebuild **only** by the signature. Confirm this by comparing with the signature
74+
excluded:
75+
- the APK Signing Block (v2/v3)
76+
- `META-INF/*.SF`, `META-INF/*.RSA`, `META-INF/*.EC`, `META-INF/*.MF` (v1)
77+
78+
**Recommended — apksigcopier** (clear pass/fail). `apksigcopier` is a Python tool and
79+
internally calls `apksigner`; **neither ships in the build image**, so install them inside
80+
a throwaway container — this does **not** change the published image (it lives only in that
81+
one container and is discarded with `--rm`). Put the downloaded signed APK at the repo root
82+
as `published.apk`, then run:
83+
```bash
84+
docker run --rm -v "$(pwd):/work" \
85+
aronbasalt/monolith-build@sha256:2c3cb23203e54f5f83cc30dfb479a49e679a8bc27c431f176d392ea3704e8dee \
86+
bash -lc '
87+
apt-get update -qq && apt-get install -y -qq python3-pip >/dev/null
88+
pip3 install --quiet apksigcopier # if pip refuses: add --break-system-packages
89+
ln -sf /opt/android-sdk/build-tools/35.0.0/apksigner /usr/local/bin/apksigner
90+
apksigcopier compare /work/published.apk --unsigned /work/app/build/outputs/apk/release/app-release-unsigned.apk
91+
echo "exit: $? (0 = identical apart from the signature = verified)"
92+
'
93+
```
94+
95+
**To inspect any remaining difference — diffoscope:**
96+
```bash
97+
diffoscope <PUBLISHED_APK> app/build/outputs/apk/release/app-release-unsigned.apk
98+
```
99+
100+
Nothing should differ other than the signature.
101+
102+
## Known exception — `libsodium.so` (prebuilt)
103+
104+
`app/src/main/cpp/libs/arm64-v8a/libsodium.so` is a **prebuilt** binary (from the
105+
Kalium JNI bindings 2.0.2), committed to the repository with a documented SHA-256
106+
(see `app/src/main/cpp/libs/PROVENANCE.md`). It is **not** compiled from source
107+
here, so it is not independently reproducible — but being fixed in Git, it
108+
contributes **identically** to every build. Trust is anchored via its SHA-256 and
109+
the Kalium release.
110+
111+
## Scope & honest limitations
112+
113+
- The comparison covers the **unsigned** APK content; the signature is intentionally
114+
excluded (only the developer holds the private signing key).
115+
- The build **image** is the trust root and is pinned **by digest**. It is published
116+
as a binary artifact and is **not** bit-for-bit rebuildable from the `Dockerfile`
117+
alone, because `apt` and `sdkmanager` fetch packages at image-build time. Verify
118+
the image by its digest to not rely on rebuilding it.
119+
- Verification is performed on `linux/amd64` (the image enforces this platform).
120+
121+
---
122+
123+
*Reproducibility setup behind this: pinned Gradle/AGP/NDK/CMake versions, Gradle
124+
dependency verification (`gradle/verification-metadata.xml`), `distributionSha256Sum`
125+
for the Gradle distribution, no VCS stamp (`vcsInfo.include = false`), neutral native
126+
build flags (`-ffile-prefix-map`, `--build-id=none`), and a digest-pinned container
127+
image.*

app/build.gradle.kts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ plugins {
99
android {
1010
namespace = "com.monolith.app"
1111
compileSdk = 36
12+
buildToolsVersion = "35.0.0"
1213

1314
// NDK r27 LTS — must be in android block, not defaultConfig
1415
ndkVersion = "27.0.12077973"
@@ -30,7 +31,7 @@ android {
3031
arguments += listOf(
3132
"-DANDROID_STL=c++_shared",
3233
"-DCMAKE_BUILD_TYPE=Release",
33-
"-DCMAKE_SHARED_LINKER_FLAGS=-Wl,-z,max-page-size=16384"
34+
"-DCMAKE_SHARED_LINKER_FLAGS=-Wl,-z,max-page-size=16384 -Wl,--build-id=none"
3435
)
3536

3637
cppFlags += listOf(
@@ -39,6 +40,7 @@ android {
3940
"-D_FORTIFY_SOURCE=2",
4041
"-Werror=format-security",
4142
"-fvisibility=hidden",
43+
"-ffile-prefix-map=/src=.",
4244
)
4345
}
4446
}
@@ -59,6 +61,10 @@ android {
5961
getDefaultProguardFile("proguard-android-optimize.txt"),
6062
"proguard-rules.pro"
6163
)
64+
65+
vcsInfo {
66+
include = false
67+
}
6268
}
6369
}
6470

@@ -75,10 +81,6 @@ android {
7581
compose = true
7682
}
7783

78-
composeOptions {
79-
kotlinCompilerExtensionVersion = "1.5.1"
80-
}
81-
8284
externalNativeBuild {
8385
cmake {
8486
path = file("src/main/cpp/CMakeLists.txt")

gradle/wrapper/gradle-wrapper.properties

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@
22
distributionBase=GRADLE_USER_HOME
33
distributionPath=wrapper/dists
44
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
5+
distributionSha256Sum=20f1b1176237254a6fc204d8434196fa11a4cfb387567519c61556e8710aed78
56
zipStoreBase=GRADLE_USER_HOME
67
zipStorePath=wrapper/dists

0 commit comments

Comments
 (0)