What changed, and why it matters
This commit fixes the firmware build process so that two people building from the same source code get the exact same binary file. Previously, the build script embedded local git details (like the repository URL, branch name, or commit hash) into the firmware, which changed the final binary depending on how the source was downloaded. The change strips those local details from release builds and adds tests to prove the output is now identical regardless of the source's git history. It is a reproducible-build hardening patch, not a fix for an exploitable vulnerability.
No immediate security action required. This is a supply-chain assurance improvement. Users who verify reproducible builds should rebuild with this patch and compare hashes; note that pre-existing v1.10.3 binaries remain non-reproducible as documented.
Security signals we found
Reproducible build hardening
Removal of build-environment-dependent metadata from release artifacts
Hash-locked dependency installation via pip --require-hashes
Added regression tests for build determinism
Evidence from the diff
The patch modifies tools/embed_git_info.py to stop running git commands during release builds. When SPECTER_REPRODUCIBLE_BUILD=1 (now set by build_firmware.sh for both bootloader and non-bootloader release builds), the generated src/git_info.py uses ‘unknown’ for REPOSITORY, BRANCH, and COMMIT instead of clone-specific values. A –reproducible CLI flag is added as an alternative trigger. Optional environment overrides (SPECTER_GIT_REPOSITORY, SPECTER_GIT_BRANCH, SPECTER_GIT_COMMIT) are supported. The Dockerfile now installs Python dependencies with –require-hashes to further lock the build environment. New native tests verify that identical source produces identical git_info output across different clone configurations and even a .git-less source archive. The docs note this does not retroactively alter the v1.10.3 release binaries.
Changed components
tools/embed_git_info.pybuild_firmware.shDockerfiledocs/reproducible-build.mdtest/tests_native/test_embed_git_info.pyInspect captured patch +218 / −19
### Dockerfile
@@ -15,8 +15,10 @@ RUN curl -sSfL -o arm-toolchain.tar.bz2 "https://developer.arm.com/-/media/Files
ENV PATH=/opt/gcc-arm-none-eabi-9-2020-q2-update/bin:$PATH
# Installing python requirements
+# requirements.txt is generated by pip-compile --generate-hashes, so pin every
+# transitive dependency to its recorded hash for a reproducible build environment.
COPY bootloader/tools/requirements.txt .
-RUN pip3 install -r requirements.txt
+RUN pip3 install --require-hashes -r requirements.txt
WORKDIR /app
### build_firmware.sh
@@ -20,6 +20,10 @@ run_main() {
echo -e "${INFO}
══════════════════════ Building main firmware ═════════════════════════════
${ENDCOLOR}"
+ # Release firmware must not embed clone-local git metadata (remote URL, branch
+ # or commit), otherwise two builders of the same source produce different
+ # frozen modules and different firmware hashes. See tools/embed_git_info.py.
+ export SPECTER_REPRODUCIBLE_BUILD=1
make clean
make disco USE_DBOOT=1
}
@@ -96,6 +100,8 @@ run_nobootloader() {
═════════════════════ Building firmware without bootloader ════════════════
${ENDCOLOR}"
+ # Same deterministic provenance as the bootloader release build.
+ export SPECTER_REPRODUCIBLE_BUILD=1
mkdir -p release
make clean
make disco
### docs/reproducible-build.md
@@ -2,6 +2,10 @@
With [docker](https://docs.docker.com/get-docker/) you can build the firmware yourself in the same environment as we do, and verify that binaries in github releases have the same hash. This way you can be sure that firmware upgrades signed by our public keys are actually built from the code in this repository, no backdoors included.
+Release builds (`./build_firmware.sh`) embed no clone-local provenance at all: the `REPOSITORY`, `BRANCH` and `COMMIT` values shown on the About screen are all `unknown`. This is what makes the build reproducible — the clone URL (HTTPS, SSH, or a fork remote), the checkout ref (branch or detached HEAD), the clone depth, and whether the source came from `git` at all or from a `.git`-less source archive all have no effect on the firmware binary. `build_firmware.sh` sets `SPECTER_REPRODUCIBLE_BUILD=1` for this; plain `make disco` dev builds still embed the live commit SHA.
+
+**Historical note:** this does not retroactively change the published `v1.10.3` tag or its binaries, whose hash still depends on the git metadata embedded when that release was built.
+
From the root of the repository:
1. Set up bootloader to use production keys:
### test/tests_native/__init__.py
@@ -5,3 +5,4 @@
from .test_change_classification import *
from .test_transaction_confirmation import *
from .test_change_security import *
+from .test_embed_git_info import *
### test/tests_native/test_embed_git_info.py
@@ -0,0 +1,141 @@
+import os
+import subprocess
+import sys
+import tempfile
+from pathlib import Path
+from unittest import TestCase
+
+REPO_ROOT = Path(__file__).resolve().parents[2]
+SCRIPT = REPO_ROOT / "tools" / "embed_git_info.py"
+
+
+def run_git(cwd: Path, *args: str) -> str:
+ return subprocess.check_output(
+ ["git", *args], cwd=cwd, text=True, stderr=subprocess.DEVNULL
+ ).strip()
+
+
+def run_script(output: Path, cwd: Path, extra_env=None) -> str:
+ env = dict(os.environ)
+ for key in ("SPECTER_REPRODUCIBLE_BUILD", "SPECTER_GIT_REPOSITORY",
+ "SPECTER_GIT_BRANCH", "SPECTER_GIT_COMMIT"):
+ env.pop(key, None)
+ if extra_env:
+ env.update(extra_env)
+ subprocess.check_call(
+ [sys.executable, str(SCRIPT), str(output)], cwd=cwd, env=env
+ )
+ return output.read_text()
+
+
+class GitInfoReproducibilityTest(TestCase):
+ def test_same_commit_ignores_remote_and_checkout_ref(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ source = root / "source"
+ clone_a = root / "clone-a"
+ clone_b = root / "clone-b"
+ source.mkdir()
+
+ run_git(source, "init")
+ run_git(source, "config", "user.name", "Specter Test")
+ run_git(source, "config", "user.email", "specter@example.invalid")
+ (source / "payload.txt").write_text("same source\n")
+ run_git(source, "add", "payload.txt")
+ run_git(source, "commit", "-m", "fixture")
+ commit = run_git(source, "rev-parse", "HEAD")
+
+ run_git(root, "clone", str(source), str(clone_a))
+ run_git(root, "clone", str(source), str(clone_b))
+
+ run_git(clone_a, "checkout", "-b", "release-test")
+ run_git(
+ clone_a,
+ "remote",
+ "set-url",
+ "origin",
+ "git@example.invalid:fork/specter-diy.git",
+ )
+
+ run_git(clone_b, "checkout", "--detach", commit)
+ run_git(
+ clone_b,
+ "remote",
+ "set-url",
+ "origin",
+ "https://example.invalid/other/specter-diy.git",
+ )
+
+ content_a = run_script(root / "git-info-a.py", clone_a)
+ content_b = run_script(root / "git-info-b.py", clone_b)
+
+ self.assertEqual(content_a, content_b)
+ # Checkout metadata is not source identity. Keeping it neutral also
+ # avoids attributing fork-only commits to the upstream repository.
+ self.assertIn("REPOSITORY = 'unknown'", content_a)
+ self.assertIn("BRANCH = 'unknown'", content_a)
+ self.assertIn("COMMIT = %r" % commit, content_a)
+ self.assertNotIn("release-test", content_a)
+ self.assertNotIn("example.invalid", content_a)
+ self.assertNotIn("cryptoadvance/specter-diy", content_a)
+
+ def test_without_git_metadata_uses_stable_unknown_values(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ output = root / "git-info.py"
+
+ content = run_script(output, root)
+
+ self.assertIn("REPOSITORY = 'unknown'", content)
+ self.assertIn("BRANCH = 'unknown'", content)
+ self.assertIn("COMMIT = 'unknown'", content)
+
+ def test_reproducible_build_output_is_source_acquisition_independent(self):
+ """Release builds (SPECTER_REPRODUCIBLE_BUILD=1) must produce identical
+ output from a git checkout and from a .git-less source archive."""
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ checkout = root / "checkout"
+ archive = root / "archive"
+ checkout.mkdir()
+
+ run_git(checkout, "init")
+ run_git(checkout, "config", "user.name", "Specter Test")
+ run_git(checkout, "config", "user.email", "specter@example.invalid")
+ (checkout / "payload.txt").write_text("same source\n")
+ run_git(checkout, "add", "payload.txt")
+ run_git(checkout, "commit", "-m", "fixture")
+ commit = run_git(checkout, "rev-parse", "HEAD")
+
+ # A source archive: same files, no .git metadata.
+ archive.mkdir()
+ (archive / "payload.txt").write_text("same source\n")
+
+ env = {"SPECTER_REPRODUCIBLE_BUILD": "1"}
+ from_checkout = run_script(root / "a.py", checkout, env)
+ from_archive = run_script(root / "b.py", archive, env)
+
+ self.assertEqual(from_checkout, from_archive)
+ self.assertIn("REPOSITORY = 'unknown'", from_checkout)
+ self.assertIn("BRANCH = 'unknown'", from_checkout)
+ self.assertIn("COMMIT = 'unknown'", from_checkout)
+ self.assertNotIn(commit, from_checkout)
+
+ def test_explicit_overrides_are_embedded(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ content = run_script(
+ root / "git-info.py",
+ root,
+ {
+ "SPECTER_GIT_REPOSITORY": "https://example.org/specter-diy",
+ "SPECTER_GIT_BRANCH": "v9.9.9",
+ "SPECTER_GIT_COMMIT": "0" * 40,
+ },
+ )
+
+ self.assertIn(
+ "REPOSITORY = 'https://example.org/specter-diy'", content
+ )
+ self.assertIn("BRANCH = 'v9.9.9'", content)
+ self.assertIn("COMMIT = '%s'" % ("0" * 40), content)
### tools/embed_git_info.py
@@ -1,15 +1,29 @@
#!/usr/bin/env python3
-"""Generate git metadata for embedding into frozen MicroPython modules."""
+"""Generate deterministic source metadata for frozen MicroPython modules."""
from __future__ import annotations
import argparse
+import os
import subprocess
from pathlib import Path
from typing import Optional
UNKNOWN_VALUE = "unknown"
+# Environment switch used by release builds (see build_firmware.sh). When set, no
+# git commands are run and every value is emitted as UNKNOWN_VALUE, so the frozen
+# module is byte-identical whether the source came from a git checkout, a shallow
+# clone, a fork, or a source archive without any .git metadata at all.
+REPRODUCIBLE_ENV = "SPECTER_REPRODUCIBLE_BUILD"
+
+# Optional explicit overrides. An empty value is treated as UNKNOWN_VALUE. These
+# let a release process pin documented provenance constants without reintroducing
+# any dependency on the local clone state.
+REPOSITORY_ENV = "SPECTER_GIT_REPOSITORY"
+BRANCH_ENV = "SPECTER_GIT_BRANCH"
+COMMIT_ENV = "SPECTER_GIT_COMMIT"
+
def _run_git(args: list[str]) -> Optional[str]:
try:
@@ -19,26 +33,48 @@ def _run_git(args: list[str]) -> Optional[str]:
return result.decode().strip() or None
+def _env_override(name: str) -> Optional[str]:
+ value = os.environ.get(name)
+ if value is None:
+ return None
+ return value.strip() or UNKNOWN_VALUE
+
+
+def _reproducible_build() -> bool:
+ return os.environ.get(REPRODUCIBLE_ENV, "").strip() not in ("", "0", "false", "False")
+
+
def discover_repository() -> str:
- repo = _run_git(["config", "--get", "remote.origin.url"])
- if repo:
- return repo
- path = _run_git(["rev-parse", "--show-toplevel"])
- return path or UNKNOWN_VALUE
+ override = _env_override(REPOSITORY_ENV)
+ if override is not None:
+ return override
+ # A clone remote is build-environment metadata, not source identity. Using
+ # a canonical upstream URL would also misattribute fork-only commits to the
+ # upstream repository, so do not embed a repository URL at all.
+ return UNKNOWN_VALUE
def discover_branch() -> str:
- branch = _run_git(["rev-parse", "--abbrev-ref", "HEAD"])
- if branch and branch != "HEAD":
- return branch
- describe = _run_git(["describe", "--all"])
- if describe:
- return describe
- return "detached"
+ override = _env_override(BRANCH_ENV)
+ if override is not None:
+ return override
+ # Branch/tag refs can differ for the same commit (branch checkout, detached
+ # HEAD, shallow clone, etc.), so embedding them breaks reproducible builds.
+ return UNKNOWN_VALUE
-def discover_commit() -> str:
- commit = _run_git(["rev-parse", "--short", "HEAD"])
+def discover_commit(reproducible: bool) -> str:
+ override = _env_override(COMMIT_ENV)
+ if override is not None:
+ return override
+ if reproducible:
+ # The full object id is only present in a git checkout; a source archive
+ # has no .git and would embed "unknown" instead. Release builds must not
+ # depend on how the source was obtained, so drop it entirely.
+ return UNKNOWN_VALUE
+ # Dev builds embed the concrete revision. Use the full object id: git's
+ # default abbreviated SHA length can vary with the objects present in a clone.
+ commit = _run_git(["rev-parse", "HEAD"])
if commit:
return commit
return UNKNOWN_VALUE
@@ -53,10 +89,10 @@ def build_content(repository: str, branch: str, commit: str) -> str:
)
-def write_git_info(path: Path) -> None:
+def write_git_info(path: Path, reproducible: bool) -> None:
repository = discover_repository()
branch = discover_branch()
- commit = discover_commit()
+ commit = discover_commit(reproducible)
content = build_content(repository, branch, commit)
@@ -81,12 +117,21 @@ def parse_args() -> argparse.Namespace:
default="src/git_info.py",
help="path to the generated git_info module",
)
+ parser.add_argument(
+ "--reproducible",
+ action="store_true",
+ help=(
+ "emit static values with no git lookups (also enabled by the "
+ "%s environment variable)" % REPRODUCIBLE_ENV
+ ),
+ )
return parser.parse_args()
def main() -> None:
args = parse_args()
- write_git_info(Path(args.output))
+ reproducible = args.reproducible or _reproducible_build()
+ write_git_info(Path(args.output), reproducible)
if __name__ == "__main__":Why this scored 15/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.