DEV Community

EME GUG
EME GUG

Posted on

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!
Enter fullscreen mode Exit fullscreen mode

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), tar và zip giữ nguyên thứ tự đó.
  • Dependency trôi: ^1.2.0 trong package.json hôm nay resolve ra 1.2.3, tuần sau ra 1.2.5.
  • Path tuyệt đối: /home/andy/project/src/... bị nhúng vào source map hoặc debug symbol.
  • User/group, locale, timezone: UID 1000 trên máy dev, UID 0 trong CI; LANG=vi_VN làm sort khác LANG=C.
  • 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.

#!/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
Enter fullscreen mode Exit fullscreen mode

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:

docker buildx build \
  --build-arg SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct) \
  --output type=image,name=myapp:1.4.0,rewrite-timestamp=true \
  .
Enter fullscreen mode Exit fullscreen mode

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:

# 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
Enter fullscreen mode Exit fullscreen mode

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.

pip install diffoscope   # hoặc: apt install diffoscope
diffoscope build1.tar.gz build2.tar.gz --html report.html
Enter fullscreen mode Exit fullscreen mode

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.

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.

Top comments (0)