--- since: 1.1.3 --- # 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. --- ## 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// 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](../runbooks/mcp-release-checklist.md)). --- ## 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 , 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 ``` 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 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](../runbooks/mcp-troubleshooting.md) §15 for the full diagnostic sequence.