gitaiflow — Release Checksums and Signing
This covers the integrity verification added to binary installation. Read it when preparing a release, rotating the signing key, or diagnosing a checksum/signature failure.
On this page ▾
1. Why this exists
There are two installation paths, and both end up running the same installer:
AOT direct install
curl -fsSL https://install.djangoplay.org/gitaiflow | bash
↓
scripts/install/install.sh
MCP install (Claude Desktop / mcp_server/binary.py)
no compatible binary found in ~/.local/bin or /usr/local/bin
↓
_run_installer() shells out to https://install.djangoplay.org/gitaiflow
↓
scripts/install/install.shBefore this, install.sh / install.ps1 downloaded a binary from R2 and
ran it with no integrity check. One signing step now covers both paths.
2. What this actually achieves — and what it doesn't
The problem it solves: R2 is a third party sitting between "we built this binary" and "the user's machine runs it." If someone gets write access to that bucket — leaked credentials, a misconfigured public ACL, a malicious insider, R2 itself compromised — they can silently swap the binary for a malicious one. Until this change, nothing on the install side would have noticed.
Why a checksum alone isn't enough: a checksum published in the same
bucket as the binary doesn't help against that scenario — whoever can
replace the binary can just as easily replace SHA256SUMS to match. A
checksum only defends against accidental corruption (a truncated
download, a bad transfer), not a deliberate swap.
What the signature adds: SHA256SUMS.sig is produced with a private
key that never touches R2, the build machine, or the release pipeline's
usual credentials — it lives only in secret storage and is used, briefly,
by whoever runs sign_release.sh. Trust is anchored to that offline key,
not to the bucket. An attacker who compromises R2 can still replace the
binary and rewrite SHA256SUMS to match it, but they cannot produce a new
SHA256SUMS.sig that verifies against the public key baked into
install.sh and binary.py — because they don't have the private key.
That's the actual guarantee: compromising the distribution channel
(R2) is no longer sufficient to get a malicious binary installed.
What this does not protect against, so nobody mistakes the scope:
- A compromised build machine producing a malicious binary BEFORE signing
(signing only certifies "this is what the release process signed off
on," not "this binary is free of bugs or malicious code")
- A compromised signing private key itself (see §4 — this is why it lives
outside the repo and outside routine release credentials)
- A user who already has a malicious binary installed before this change
shipped (verification only runs on new installs/updates)
- Anything after the binary is verified and running (this is supply-chain
integrity for distribution, not a sandbox or runtime protection)There is also no server-side revocation (§6) — if a signing key is compromised, the fix is rotating it and shipping the new public key, not flipping a switch on R2.
3. What gets verified, and where
SHA256SUMS mandatory everywhere — catches corruption or a swapped file
SHA256SUMS.sig Ed25519 signature over SHA256SUMS — catches a compromised bucket| Install path | Checksum | Signature |
|---|---|---|
install.sh (macOS/Linux) |
mandatory | best-effort (needs OpenSSL ≥ 3.2 raw Ed25519 support) |
install.ps1 (Windows) |
mandatory | not attempted (no reliable Ed25519 support in this PowerShell host) |
mcp_server/binary.py (MCP, both OSes) |
mandatory | mandatory |
binary.py re-verifies independently after install.sh/install.ps1
finishes — it does not trust that the installer already checked anything,
since the installer runs as a separate, possibly-tampered-with process.
This is also why the signature check is unconditional there even on
platforms where the shell installer can only do a best-effort check: every
MCP install ends up covered regardless of the host's OpenSSL version.
A failure at any mandatory point aborts the install. Nothing partially installs.
4. Key management
Public key baked into scripts/install/install.sh and mcp_server/binary.py
Private key held outside the repo — secrets manager / CI secret store onlyThe public key is a constant in both files (GITAIFLOW_SIGNING_PUBKEY_PEM
in install.sh, _RELEASE_SIGNING_PUBKEY_B64 in binary.py). It is
intentionally not fetched from R2 — a key served from the same bucket
as the binary would let a compromised bucket rotate its own trust anchor,
which defeats the entire point in §2.
The matching private key is never committed. scripts/release/sign_release.sh
expects it via GITAIFLOW_SIGNING_KEY_FILE, pointing at a file containing
the base64-encoded 32-byte raw Ed25519 private key.
install.ps1 has no embedded key: it has nothing to check it against.
5. Signing a release
GITAIFLOW_SIGNING_KEY_FILE=/path/to/release-signing.key.b64 \
./scripts/release/sign_release.sh v1.1.3This writes SHA256SUMS and SHA256SUMS.sig into
installers/AOT/v1.1.3/, alongside the built binaries.
scripts/r2/gitai_r2.sh calls this automatically for any installers/AOT/v*
directory that doesn't already have a SHA256SUMS.sig, but only when
GITAIFLOW_SIGNING_KEY_FILE is set in its environment. Without it, the
sync proceeds unsigned — with a warning — which is fine for a local test
sync but must never reach the production R2 bucket.
[ ] GITAIFLOW_SIGNING_KEY_FILE set before running gitai_r2.sh for a real release
[ ] SHA256SUMS.sig present in installers/AOT/<version>/ before syncing
[ ] release not promoted to production if gitai_r2.sh printed the "skipping release signing" warningThis belongs in the release checklist's packaging gate (see MCP release checklist).
6. Rotating the key
./scripts/release/rotate_signing_key.shThis generates a new Ed25519 keypair and swaps the embedded public
key into both scripts/install/install.sh and mcp_server/binary.py in
one step, then verifies the two files agree on the same key before
finishing. It prints the new private key's location (a fresh directory
under /tmp by default — outside the repo on purpose — or pass
--out-dir DIR) and appends a timestamp + public key line (never secret
material) to scripts/release/KEY_ROTATIONS.log.
This script does not, and cannot safely, do the rest of rotation for you:
1. Move the printed private key file into real secret storage now,
then delete the local copy (e.g. shred -u <file>, or rm -P on macOS).
2. Release the updated install.sh / MCP extension with the new public
key BEFORE signing anything with the new private key -- an
unupgraded install still running the old binary.py/install.sh will
reject a release signed with a key it doesn't recognize yet.
3. Point future scripts/release/sign_release.sh runs (directly, or via
GITAIFLOW_SIGNING_KEY_FILE for gitai_r2.sh) at the new key's stored
location.
4. Once no supported release still depends on it, retire the old
private key from active use.This is intentionally a separate, manually-triggered script — it is
not called from gitai_r2.sh or any other per-release automation.
Rotation should be rare and deliberate: a suspected key compromise, a
scheduled policy, someone leaving the team. Automating it into every
release would mean every release invalidates every existing install's
trust anchor for no reason, which is the opposite of what signing is for.
There is no server-side key revocation; the trust anchor is whatever public key shipped in the installer a given user already has.
7. Troubleshooting
Error: checksum mismatch for <asset> <version>The download doesn't match what was published. Retry; if it persists, treat it as a possible bucket compromise, not a fluke.
Release signature for gitaiflow <version> did not verify.(mcp_server/binary.py) The SHA256SUMS served for this version wasn't
signed by the embedded key. Do not relax this check to work around it —
find out why the signature doesn't match first.
Warning: could not cryptographically verify the release signature(install.sh) OpenSSL on this machine lacks raw Ed25519 support. The
mandatory checksum above it still ran. If this install also goes through
Claude Desktop's MCP extension, binary.py's mandatory check still covers
it independently.
See MCP troubleshooting §15 for the full diagnostic sequence.