Reproducible Builds: What F-Droid Teaches Us About Trusting Our Own Binaries
Tuần này F-Droid 2.0 lên top Hacker News với gần 1000 điểm. Phần lớn mọi người bàn về UI mới, nhưng thứ mình thấy đáng học nhất là thứ F-Droid đã làm suốt nhiều năm: reproducible builds. Ý tưởng rất đơn giản: cùng một source code, build ở hai máy khác nhau, phải ra cùng một file binary, giống nhau từng byte. Nếu không giống, có gì đó sai: hoặc môi trường build khác, hoặc tệ hơn là có ai đó đã chèn thứ gì vào pipeline.
Nghe thì có vẻ chỉ dành cho distro Linux hay app store, nhưng thực tế team nào có CI/CD, Docker image hay package npm/PyPI đều nên quan tâm. Bài này mình chia sẻ cách mình áp dụng reproducible builds vào project thực tế, những chỗ hay bị lệch hash, và cách debug khi hai bản build không khớp.
Tại sao build lại không reproducible?
Bạn thử làm thí nghiệm nhỏ: build project hai lần liên tiếp rồi so sánh hash. Rất có thể kết quả khác nhau, dù bạn không sửa dòng code nào.
# Build 2 lần, so sánh sha256
npm ci && npm run build && tar -cf build1.tar dist/
sleep 2
rm -rf dist && npm run build && tar -cf build2.tar dist/
sha256sum build1.tar build2.tar
# a3f1... build1.tar
# 9c07... build2.tar <- khác nhau!
Những thủ phạm phổ biến nhất mình hay gặp:
- Timestamp: file mtime trong tar/zip/jar, hoặc
new Date()được nhúng vào bundle làm build version. -
- Thứ tự file: filesystem trả về thứ tự khác nhau (ext4 vs APFS),
tarvàzipgiữ nguyên thứ tự đó.
- Thứ tự file: filesystem trả về thứ tự khác nhau (ext4 vs APFS),
-
- Dependency trôi:
^1.2.0trongpackage.jsonhôm nay resolve ra 1.2.3, tuần sau ra 1.2.5.
- Dependency trôi:
-
- Path tuyệt đối:
/home/andy/project/src/...bị nhúng vào source map hoặc debug symbol.
- Path tuyệt đối:
-
- User/group, locale, timezone: UID 1000 trên máy dev, UID 0 trong CI;
LANG=vi_VNlàm sort khácLANG=C.
- User/group, locale, timezone: UID 1000 trên máy dev, UID 0 trong CI;
-
- Randomness: hash map ordering, UUID sinh lúc build, parallel build ghi file theo thứ tự không cố định.
flowchart LR
S[Source code + lockfile] --> B1[Build tại máy dev]
S --> B2[Build tại CI độc lập]
B1 --> A1[Artifact A]
B2 --> A2[Artifact B]
A1 --> C{sha256 giống nhau?}
A2 --> C
C -->|Có| P[Publish + ký]
C -->|Không| D[diffoscope tìm nguyên nhân]
```
Đây chính là mô hình F-Droid dùng: developer build và ký APK, F-Droid build lại độc lập từ source, nếu hai bản khớp thì F-Droid publish luôn bản có chữ ký gốc của developer. User vừa có chữ ký của tác giả, vừa có bằng chứng binary thật sự đến từ source công khai.
## Khử timestamp và thứ tự file với SOURCE_DATE_EPOCH
Chuẩn chung mà cộng đồng reproducible-builds.org đưa ra là biến môi trường `SOURCE_DATE_EPOCH`. Thay vì dùng giờ hiện tại, mọi tool build dùng một timestamp cố định, thường lấy từ commit cuối cùng. GCC, `dpkg`, Python `setuptools`, Go, esbuild, và rất nhiều tool khác đã hỗ trợ sẵn.
```bash
#!/usr/bin/env bash
set -euo pipefail
# Timestamp cố định = thời điểm commit cuối
export SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct)
export TZ=UTC
export LC_ALL=C
npm ci --ignore-scripts
npm run build
# Đóng gói tar deterministic (GNU tar >= 1.28)
tar --sort=name \
--mtime="@${SOURCE_DATE_EPOCH}" \
--owner=0 --group=0 --numeric-owner \
--pax-option=exthdr.name=%d/PaxHeaders/%f,delete=atime,delete=ctime \
-cf release.tar dist/
gzip -n -9 release.tar # -n: không ghi tên file và timestamp vào header
sha256sum release.tar.gz
```
Vài điểm cần để ý:
- `gzip -n` rất hay bị quên. Mặc định gzip ghi mtime vào header nên hash sẽ khác dù nội dung tar giống hệt.
- Trên macOS, `tar` mặc định là bsdtar, không có `--sort`. Cài `gnu-tar` qua Homebrew rồi dùng `gtar`, hoặc build trong container luôn cho chắc.
- Trong code, đừng nhúng `new Date().toISOString()` làm build time. Nếu cần, đọc từ `process.env.SOURCE_DATE_EPOCH`.
Với Docker, BuildKit từ v0.13 đã hỗ trợ `SOURCE_DATE_EPOCH` khi build image:
```bash
docker buildx build \
--build-arg SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct) \
--output type=image,name=myapp:1.4.0,rewrite-timestamp=true \
.
```
Nhớ pin base image bằng digest (`FROM node:22-slim@sha256:...`) thay vì chỉ tag, vì tag `node:22-slim` có thể trỏ sang image khác vào tuần sau.
## Khóa chặt dependency
Timestamp chỉ là phần dễ. Phần khó hơn là đảm bảo dependency giống nhau tuyệt đối. Quy tắc của mình:
- **Node.js**: luôn commit `package-lock.json`, CI dùng `npm ci` chứ không dùng `npm install`. Với pnpm thì `pnpm install --frozen-lockfile`.
- **Python**: dùng `uv` (0.4+) hoặc `pip-tools` để sinh lockfile có hash.
- **Go**: `go.sum` + build với `-trimpath` để bỏ path tuyệt đối.
Với Python, mình thích cách này vì pip sẽ từ chối cài nếu hash không khớp, chặn luôn cả trường hợp package bị thay thế trên registry:
```bash
# Sinh lockfile có hash
uv pip compile requirements.in --generate-hashes -o requirements.txt
# Cài đặt: fail ngay nếu có package nào sai hash
pip install --require-hashes --no-deps -r requirements.txt
# Build wheel reproducible
export SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct)
export PYTHONHASHSEED=0
python -m build --wheel
```
`PYTHONHASHSEED=0` là chi tiết nhỏ nhưng quan trọng: nếu build script có iterate qua `set` hoặc sinh file từ dict hash ngẫu nhiên, thứ tự output sẽ thay đổi mỗi lần chạy.
## Debug khi hash không khớp: diffoscope
Khi hai bản build khác nhau, `sha256sum` chỉ cho biết là khác, không cho biết khác ở đâu. Tool cứu cánh là **diffoscope**, được phát triển bởi chính nhóm Reproducible Builds. Nó mở đệ quy tar, zip, jar, APK, wheel, ELF, PDF... và chỉ ra chính xác chỗ khác biệt.
```bash
pip install diffoscope # hoặc: apt install diffoscope
diffoscope build1.tar.gz build2.tar.gz --html report.html
```
Một lần mình debug một wheel Python mãi không khớp, diffoscope chỉ ra ngay: file `RECORD` bên trong wheel có thứ tự dòng khác nhau, do một plugin build dùng `os.listdir()` mà không sort. Sửa một dòng `sorted()` là xong, trong khi nếu đoán mò có khi mất cả buổi chiều.
```mermaid
sequenceDiagram
participant CI as CI Runner
participant V as Verifier Job
participant R as Registry
CI->>CI: Build + sha256
V->>V: Build lại từ cùng commit
V->>CI: So sánh hash
alt Khớp
CI->>R: Publish artifact đã ký
else Không khớp
V->>V: Chạy diffoscope, fail pipeline
end
```
Trong GitHub Actions hoặc GitLab CI, mình setup đơn giản: hai job chạy song song trên hai runner khác nhau (ví dụ `ubuntu-24.04` và `ubuntu-22.04` trong container giống nhau), cả hai upload hash, job thứ ba so sánh. Nếu lệch thì fail pipeline và upload report diffoscope làm artifact để xem.
## Kết luận
Reproducible builds không phải chuyện chỉ dành cho Debian hay F-Droid. Với các vụ supply chain attack ngày càng nhiều, việc chứng minh được binary đến từ đúng source code là một lớp phòng thủ rất rẻ mà hiệu quả. Bonus: build reproducible thì cache của CI cũng hit tốt hơn, và bug kiểu chạy được ở máy tôi giảm hẳn.
Những việc bạn có thể làm ngay tuần này:
1. **Thử nghiệm**: build project hai lần, so `sha256sum`. Biết mình đang ở đâu đã.
2. **Set `SOURCE_DATE_EPOCH`, `TZ=UTC`, `LC_ALL=C`** trong script build và CI.
3. **Dùng lockfile nghiêm túc**: `npm ci`, `pnpm --frozen-lockfile`, `pip --require-hashes`, `go build -trimpath`.
4. **Pin base image bằng digest**, không chỉ bằng tag.
5. **Đóng gói deterministic**: `tar --sort=name --mtime`, `gzip -n`.
6. **Cài diffoscope** và dùng nó mỗi khi hash lệch thay vì đoán.
7. Khi đã ổn, thêm **một verifier job** trong CI để build lại và so hash trước khi publish.
Không cần đạt 100% ngay từ đầu. Chỉ cần artifact chính (Docker image, package release) reproducible là bạn đã hơn phần lớn các project ngoài kia rồi.
All rights reserved