gitaiflow / Deployment / gitaiflow — Release Checksums and Signing
DocsgitaiflowDeploymentgitaiflow — Release Checksums and Signing

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.

6 min readApplies to v1.1.3Added in 1.1.3
On this page ▾
  1. 1. Why this exists
  2. 2. What this actually achieves — and what it doesn't
  3. 3. What gets verified, and where
  4. 4. Key management
  5. 5. Signing a release
  6. 6. Rotating the key
  7. 7. Troubleshooting

1. Why this exists

There are two installation paths, and both end up running the same installer:

text
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.sh

Before 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:

text
- 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

text
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

text
Public key   baked into scripts/install/install.sh and mcp_server/binary.py
Private key  held outside the repo — secrets manager / CI secret store only

The 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

bash
GITAIFLOW_SIGNING_KEY_FILE=/path/to/release-signing.key.b64 \
  ./scripts/release/sign_release.sh v1.1.3

This 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.

text
[ ] 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" warning

This belongs in the release checklist's packaging gate (see MCP release checklist).


6. Rotating the key

bash
./scripts/release/rotate_signing_key.sh

This 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:

text
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

text
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.

text
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.

text
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.