0

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), 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.
                                
                                ```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

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí