Sign release checksums with Ed25519 (#63) #64
@@ -62,8 +62,9 @@ jobs:
|
|||||||
|
|
||||||
# bcc_core has no GUI imports, so the test suite needs no PySide6 —
|
# bcc_core has no GUI imports, so the test suite needs no PySide6 —
|
||||||
# keeps CI fast and avoids Qt system-library headaches on the runner.
|
# keeps CI fast and avoids Qt system-library headaches on the runner.
|
||||||
|
# cryptography is for tests/test_checksums.py (release signing helper).
|
||||||
- name: Install test dependencies
|
- name: Install test dependencies
|
||||||
run: pip install pytest
|
run: pip install pytest cryptography
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: python -m pytest -v
|
run: python -m pytest -v
|
||||||
|
|||||||
@@ -107,11 +107,72 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
# Needed for scripts/sign_checksums.py — the release job otherwise
|
||||||
|
# only downloads build artifacts, it doesn't check out the repo.
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Download all artifacts
|
- name: Download all artifacts
|
||||||
uses: actions/download-artifact@v3
|
uses: actions/download-artifact@v3
|
||||||
with:
|
with:
|
||||||
path: artifacts
|
path: artifacts
|
||||||
|
|
||||||
|
- name: Set up Python 3.12
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
# download-artifact@v3 nests each artifact under a directory named
|
||||||
|
# after it (artifacts/<name>/<name>). Flatten into one directory so
|
||||||
|
# SHA256SUMS lists plain filenames, matching what `sha256sum -c`
|
||||||
|
# expects when run from inside an extracted release download.
|
||||||
|
- name: Collect release files
|
||||||
|
run: |
|
||||||
|
mkdir -p release-files
|
||||||
|
find artifacts -type f -exec cp {} release-files/ \;
|
||||||
|
ls -la release-files
|
||||||
|
|
||||||
|
- name: Generate SHA256SUMS
|
||||||
|
run: python3 scripts/sign_checksums.py generate release-files --out release-files/SHA256SUMS
|
||||||
|
|
||||||
|
# ── Sign the checksum manifest (best-effort) ──────────────────────
|
||||||
|
#
|
||||||
|
# BCC binaries are not code-signed (no budget for a paid cert). This
|
||||||
|
# is the free half: a checksum manifest, detached-signed with
|
||||||
|
# Ed25519, so a tampered download is detectable by anyone who
|
||||||
|
# checks. It does NOT remove Gatekeeper/SmartScreen warnings.
|
||||||
|
#
|
||||||
|
# The private key is a repo secret (RELEASE_SIGNING_KEY, base64 raw
|
||||||
|
# Ed25519 seed) generated via the Catalog Console (#62). If it's not
|
||||||
|
# set, we still publish the release — just without a .sig — rather
|
||||||
|
# than fail the release outright.
|
||||||
|
- name: Check for signing key
|
||||||
|
id: signing
|
||||||
|
run: |
|
||||||
|
if [ -n "${{ secrets.RELEASE_SIGNING_KEY }}" ]; then
|
||||||
|
echo "has_key=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "has_key=false" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Install signing dependencies
|
||||||
|
if: steps.signing.outputs.has_key == 'true'
|
||||||
|
run: pip install cryptography
|
||||||
|
|
||||||
|
- name: Sign SHA256SUMS
|
||||||
|
if: steps.signing.outputs.has_key == 'true'
|
||||||
|
env:
|
||||||
|
RELEASE_SIGNING_KEY: ${{ secrets.RELEASE_SIGNING_KEY }}
|
||||||
|
run: |
|
||||||
|
python3 scripts/sign_checksums.py sign \
|
||||||
|
--sums release-files/SHA256SUMS \
|
||||||
|
--out release-files/SHA256SUMS.sig
|
||||||
|
|
||||||
|
- name: Warn — release will be unsigned
|
||||||
|
if: steps.signing.outputs.has_key != 'true'
|
||||||
|
run: |
|
||||||
|
echo "::warning::RELEASE_SIGNING_KEY secret is not set — this release is being published WITHOUT a signed SHA256SUMS.sig. Add the secret (base64 raw Ed25519 seed, generated via the Catalog Console, #62) before the next tag."
|
||||||
|
|
||||||
- name: Create GitHub Release
|
- name: Create GitHub Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
@@ -119,7 +180,9 @@ jobs:
|
|||||||
draft: false
|
draft: false
|
||||||
prerelease: false
|
prerelease: false
|
||||||
generate_release_notes: false
|
generate_release_notes: false
|
||||||
files: artifacts/**/*
|
files: |
|
||||||
|
artifacts/**/*
|
||||||
|
release-files/SHA256SUMS*
|
||||||
body: |
|
body: |
|
||||||
## Better Claude Config ${{ github.ref_name }}
|
## Better Claude Config ${{ github.ref_name }}
|
||||||
|
|
||||||
@@ -139,5 +202,8 @@ jobs:
|
|||||||
xattr -cr /Applications/BetterClaudeConfig.app
|
xattr -cr /Applications/BetterClaudeConfig.app
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Verifying your download
|
||||||
|
Every release includes `SHA256SUMS` (and, when the signing key is configured, a detached `SHA256SUMS.sig`). See [Verifying your download](https://git.avezzano.io/the_og/better-claude-config#verifying-your-download) in the README for commands. This proves you got the file we published — it does not remove Gatekeeper/SmartScreen warnings.
|
||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
No Python installation needed — the app is self-contained.
|
No Python installation needed — the app is self-contained.
|
||||||
|
|||||||
@@ -19,6 +19,65 @@ Pre-built self-contained binaries are attached to every [GitHub Release](../../r
|
|||||||
|
|
||||||
> **macOS Gatekeeper note:** the app is not notarized. On first launch, right-click → **Open**, or run `xattr -cr /Applications/BetterClaudeConfig.app` in a terminal.
|
> **macOS Gatekeeper note:** the app is not notarized. On first launch, right-click → **Open**, or run `xattr -cr /Applications/BetterClaudeConfig.app` in a terminal.
|
||||||
|
|
||||||
|
## Verifying your download
|
||||||
|
|
||||||
|
BCC isn't code-signed — there's no budget for a paid certificate (macOS
|
||||||
|
Developer ID, Windows Authenticode). Instead, every release publishes a
|
||||||
|
`SHA256SUMS` file listing the checksum of each archive, detached-signed with
|
||||||
|
Ed25519 as `SHA256SUMS.sig`. Both are attached to the release alongside the
|
||||||
|
binaries.
|
||||||
|
|
||||||
|
**What this proves:** the file you downloaded is byte-for-byte what we
|
||||||
|
published, and the manifest itself was signed by our release key.
|
||||||
|
|
||||||
|
**What this does NOT do:** it does not make the binary "safe," and it does
|
||||||
|
**not** remove the macOS Gatekeeper or Windows SmartScreen warning — those
|
||||||
|
are only suppressed by a paid OS-vendor certificate, which this project
|
||||||
|
doesn't have. Verifying checksums is about detecting tampering in transit or
|
||||||
|
on a mirror, not about vouching for the software.
|
||||||
|
|
||||||
|
**Release signing public key** (Ed25519, base64, raw 32 bytes):
|
||||||
|
|
||||||
|
```
|
||||||
|
<PLACEHOLDER — AJ: paste the public key from the Catalog Console (#62) here>
|
||||||
|
```
|
||||||
|
|
||||||
|
### macOS / Linux
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From inside the folder you downloaded the release files into:
|
||||||
|
sha256sum -c SHA256SUMS
|
||||||
|
```
|
||||||
|
|
||||||
|
If your `sha256sum` complains about missing files, download `SHA256SUMS`
|
||||||
|
into the same directory as the archive you downloaded — it lists every
|
||||||
|
platform's archive, and only the one(s) present will be checked.
|
||||||
|
|
||||||
|
To also verify the manifest's signature (optional, requires Python +
|
||||||
|
`pip install cryptography` and a checkout of this repo):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/sign_checksums.py verify \
|
||||||
|
--sums SHA256SUMS --sig SHA256SUMS.sig \
|
||||||
|
--pubkey-b64 "<the public key above>"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Windows (PowerShell)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Get-FileHash .\BetterClaudeConfig-Windows.zip -Algorithm SHA256
|
||||||
|
```
|
||||||
|
|
||||||
|
Compare the printed hash (case-insensitively) against the matching line in
|
||||||
|
`SHA256SUMS`.
|
||||||
|
|
||||||
|
### If a release has no `SHA256SUMS.sig`
|
||||||
|
|
||||||
|
The signing key is a repo secret that has to be configured manually; if a
|
||||||
|
release is missing the `.sig` file, the checksums themselves are still
|
||||||
|
valid and safe to check against — the release workflow only skips signing,
|
||||||
|
never checksum generation.
|
||||||
|
|
||||||
## Run from source
|
## Run from source
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -92,6 +151,7 @@ file is also listed, marked *legacy*, so you can copy them over.
|
|||||||
- `test_core.py` — unit suite for the core (`python test_core.py`).
|
- `test_core.py` — unit suite for the core (`python test_core.py`).
|
||||||
- `bcc.spec` — PyInstaller build spec (cross-platform).
|
- `bcc.spec` — PyInstaller build spec (cross-platform).
|
||||||
- `scripts/build_icons.py` — regenerates `icons/app.icns` and `icons/app.ico` from source PNGs.
|
- `scripts/build_icons.py` — regenerates `icons/app.icns` and `icons/app.ico` from source PNGs.
|
||||||
|
- `scripts/sign_checksums.py` — generates and Ed25519-signs the release `SHA256SUMS` manifest (see [Verifying your download](#verifying-your-download)).
|
||||||
|
|
||||||
## Building from source
|
## Building from source
|
||||||
|
|
||||||
|
|||||||
@@ -8,3 +8,4 @@ pillow>=10.0 # generates icons/app.ico during CI (Windows build)
|
|||||||
# Test / lint
|
# Test / lint
|
||||||
pytest>=8.0
|
pytest>=8.0
|
||||||
ruff>=0.6
|
ruff>=0.6
|
||||||
|
cryptography>=42.0 # release checksum signing (scripts/sign_checksums.py)
|
||||||
|
|||||||
Executable
+235
@@ -0,0 +1,235 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Generate a SHA256SUMS file for release artifacts and sign it with Ed25519.
|
||||||
|
|
||||||
|
BCC ships PyInstaller binaries that are not code-signed (no budget for a
|
||||||
|
macOS Developer ID / Windows Authenticode certificate). This script provides
|
||||||
|
the free half of supply-chain integrity: a checksum manifest, detached-signed
|
||||||
|
so downloaders can verify the file they got is the file we published.
|
||||||
|
|
||||||
|
This does NOT remove Gatekeeper/SmartScreen warnings and does NOT prove the
|
||||||
|
binary is safe to run -- only that it matches what the release signing key
|
||||||
|
attested to.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
# Hash every file in a directory into a SHA256SUMS-format manifest.
|
||||||
|
python scripts/sign_checksums.py generate <dir> --out SHA256SUMS
|
||||||
|
|
||||||
|
# Sign a manifest, producing a detached signature.
|
||||||
|
# Private key comes from $RELEASE_SIGNING_KEY (base64 raw Ed25519 seed)
|
||||||
|
# unless --key-b64 is given explicitly (mostly for tests).
|
||||||
|
python scripts/sign_checksums.py sign --sums SHA256SUMS --out SHA256SUMS.sig
|
||||||
|
|
||||||
|
# Verify a manifest against a detached signature and a public key.
|
||||||
|
python scripts/sign_checksums.py verify --sums SHA256SUMS --sig SHA256SUMS.sig \
|
||||||
|
--pubkey-b64 <base64 raw Ed25519 public key>
|
||||||
|
|
||||||
|
The private key is generated and rotated via the Catalog Console (#62) --
|
||||||
|
this script never generates or stores a key itself.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Domain separation prefix: ties every signature to "a BCC release checksum
|
||||||
|
# manifest" so a signature can never be replayed against an unrelated
|
||||||
|
# message signed by the same key.
|
||||||
|
DOMAIN_PREFIX = b"bcc-release-v1|"
|
||||||
|
|
||||||
|
CHUNK_SIZE = 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def sha256_file(path: Path) -> str:
|
||||||
|
"""Return the lowercase hex SHA-256 digest of a file's contents."""
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with open(path, "rb") as fh:
|
||||||
|
while chunk := fh.read(CHUNK_SIZE):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def build_checksums_text(files: dict[str, str]) -> str:
|
||||||
|
"""Build a sha256sum(1)-compatible manifest body.
|
||||||
|
|
||||||
|
`files` maps filename -> hex digest. Entries are sorted by filename for
|
||||||
|
a deterministic, diffable output. Format matches `sha256sum` exactly:
|
||||||
|
"<hash> <filename>\n" (two spaces, no path components).
|
||||||
|
"""
|
||||||
|
lines = [f"{digest} {name}" for name, digest in sorted(files.items())]
|
||||||
|
body = "\n".join(lines)
|
||||||
|
return body + "\n" if body else ""
|
||||||
|
|
||||||
|
|
||||||
|
def generate_checksums(directory: Path, *, exclude: set[str] | None = None) -> str:
|
||||||
|
"""Hash every regular file directly inside `directory` (non-recursive)
|
||||||
|
and return the SHA256SUMS text. Filenames are recorded without any
|
||||||
|
directory prefix so the manifest can be verified from inside the
|
||||||
|
directory it describes.
|
||||||
|
"""
|
||||||
|
exclude = exclude or set()
|
||||||
|
files: dict[str, str] = {}
|
||||||
|
for entry in sorted(directory.iterdir()):
|
||||||
|
if not entry.is_file():
|
||||||
|
continue
|
||||||
|
if entry.name in exclude:
|
||||||
|
continue
|
||||||
|
files[entry.name] = sha256_file(entry)
|
||||||
|
return build_checksums_text(files)
|
||||||
|
|
||||||
|
|
||||||
|
def _signing_message(sums_text: str) -> bytes:
|
||||||
|
"""The exact bytes that get signed: the domain prefix followed by the
|
||||||
|
raw bytes of the SHA256SUMS file content."""
|
||||||
|
return DOMAIN_PREFIX + sums_text.encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def sign_checksums(seed_b64: str, sums_text: str) -> bytes:
|
||||||
|
"""Sign `sums_text` with the Ed25519 private key encoded (base64, raw
|
||||||
|
32-byte seed) in `seed_b64`. Returns the raw 64-byte signature."""
|
||||||
|
# Imported lazily so `generate` mode (used on every CI run) never
|
||||||
|
# requires the `cryptography` package to be installed.
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||||
|
|
||||||
|
seed = base64.b64decode(seed_b64)
|
||||||
|
if len(seed) != 32:
|
||||||
|
raise ValueError(f"expected a 32-byte raw Ed25519 seed, got {len(seed)} bytes")
|
||||||
|
private_key = Ed25519PrivateKey.from_private_bytes(seed)
|
||||||
|
return private_key.sign(_signing_message(sums_text))
|
||||||
|
|
||||||
|
|
||||||
|
def verify_checksums(pubkey_b64: str, sums_text: str, signature: bytes) -> bool:
|
||||||
|
"""Verify `signature` over `sums_text` against the base64-encoded raw
|
||||||
|
32-byte Ed25519 public key. Returns True/False; never raises for a bad
|
||||||
|
signature (only for malformed inputs)."""
|
||||||
|
from cryptography.exceptions import InvalidSignature
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
||||||
|
|
||||||
|
pubkey_bytes = base64.b64decode(pubkey_b64)
|
||||||
|
if len(pubkey_bytes) != 32:
|
||||||
|
raise ValueError(
|
||||||
|
f"expected a 32-byte raw Ed25519 public key, got {len(pubkey_bytes)} bytes"
|
||||||
|
)
|
||||||
|
public_key = Ed25519PublicKey.from_public_bytes(pubkey_bytes)
|
||||||
|
try:
|
||||||
|
public_key.verify(signature, _signing_message(sums_text))
|
||||||
|
return True
|
||||||
|
except InvalidSignature:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def public_key_b64_from_seed(seed_b64: str) -> str:
|
||||||
|
"""Derive the base64 raw public key from a base64 raw seed. Handy for
|
||||||
|
local key-pair sanity checks; not used by the release workflow."""
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||||
|
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
|
||||||
|
|
||||||
|
seed = base64.b64decode(seed_b64)
|
||||||
|
private_key = Ed25519PrivateKey.from_private_bytes(seed)
|
||||||
|
raw = private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
|
||||||
|
return base64.b64encode(raw).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# CLI
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _cmd_generate(args: argparse.Namespace) -> int:
|
||||||
|
directory = Path(args.directory)
|
||||||
|
exclude = {"SHA256SUMS", "SHA256SUMS.sig"}
|
||||||
|
text = generate_checksums(directory, exclude=exclude)
|
||||||
|
out_path = Path(args.out)
|
||||||
|
out_path.write_text(text, encoding="utf-8")
|
||||||
|
print(f"Wrote {out_path} ({len(text.splitlines())} entries)")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _cmd_sign(args: argparse.Namespace) -> int:
|
||||||
|
seed_b64 = args.key_b64 or os.environ.get(args.key_env, "")
|
||||||
|
if not seed_b64:
|
||||||
|
print(
|
||||||
|
f"error: no signing key provided (checked --key-b64 and ${args.key_env})",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
sums_text = Path(args.sums).read_text(encoding="utf-8")
|
||||||
|
signature = sign_checksums(seed_b64, sums_text)
|
||||||
|
Path(args.out).write_bytes(signature)
|
||||||
|
print(f"Wrote {args.out} ({len(signature)} bytes)")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _cmd_verify(args: argparse.Namespace) -> int:
|
||||||
|
pubkey_b64 = args.pubkey_b64 or os.environ.get(args.pubkey_env, "")
|
||||||
|
if not pubkey_b64:
|
||||||
|
print(
|
||||||
|
f"error: no public key provided (checked --pubkey-b64 and ${args.pubkey_env})",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
sums_text = Path(args.sums).read_text(encoding="utf-8")
|
||||||
|
signature = Path(args.sig).read_bytes()
|
||||||
|
ok = verify_checksums(pubkey_b64, sums_text, signature)
|
||||||
|
if ok:
|
||||||
|
print("OK: signature is valid")
|
||||||
|
return 0
|
||||||
|
print("FAILED: signature is invalid", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
|
||||||
|
)
|
||||||
|
sub = parser.add_subparsers(dest="mode", required=True)
|
||||||
|
|
||||||
|
p_gen = sub.add_parser(
|
||||||
|
"generate", help="hash every file in a directory into a SHA256SUMS manifest"
|
||||||
|
)
|
||||||
|
p_gen.add_argument("directory", help="directory whose files should be hashed (non-recursive)")
|
||||||
|
p_gen.add_argument("--out", required=True, help="path to write the SHA256SUMS manifest to")
|
||||||
|
p_gen.set_defaults(func=_cmd_generate)
|
||||||
|
|
||||||
|
p_sign = sub.add_parser("sign", help="detached-sign a SHA256SUMS manifest with Ed25519")
|
||||||
|
p_sign.add_argument("--sums", required=True, help="path to the SHA256SUMS manifest to sign")
|
||||||
|
p_sign.add_argument("--out", required=True, help="path to write the detached signature to")
|
||||||
|
p_sign.add_argument(
|
||||||
|
"--key-b64", default=None, help="base64 raw Ed25519 seed (overrides --key-env)"
|
||||||
|
)
|
||||||
|
p_sign.add_argument(
|
||||||
|
"--key-env",
|
||||||
|
default="RELEASE_SIGNING_KEY",
|
||||||
|
help="environment variable holding the base64 seed (default: RELEASE_SIGNING_KEY)",
|
||||||
|
)
|
||||||
|
p_sign.set_defaults(func=_cmd_sign)
|
||||||
|
|
||||||
|
p_verify = sub.add_parser(
|
||||||
|
"verify", help="verify a SHA256SUMS manifest against a detached signature"
|
||||||
|
)
|
||||||
|
p_verify.add_argument("--sums", required=True, help="path to the SHA256SUMS manifest")
|
||||||
|
p_verify.add_argument("--sig", required=True, help="path to the detached signature")
|
||||||
|
p_verify.add_argument(
|
||||||
|
"--pubkey-b64", default=None, help="base64 raw Ed25519 public key (overrides --pubkey-env)"
|
||||||
|
)
|
||||||
|
p_verify.add_argument(
|
||||||
|
"--pubkey-env",
|
||||||
|
default="RELEASE_SIGNING_PUBKEY",
|
||||||
|
help="environment variable holding the base64 public key (default: RELEASE_SIGNING_PUBKEY)",
|
||||||
|
)
|
||||||
|
p_verify.set_defaults(func=_cmd_verify)
|
||||||
|
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
return args.func(args)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
"""Tests for scripts/sign_checksums.py: SHA256SUMS generation and detached
|
||||||
|
Ed25519 signing/verification for release artifacts."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
|
||||||
|
|
||||||
|
import sign_checksums as sc
|
||||||
|
|
||||||
|
cryptography = pytest.importorskip("cryptography")
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey # noqa: E402
|
||||||
|
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _make_keypair() -> tuple[str, str]:
|
||||||
|
"""Return (seed_b64, pubkey_b64) for a fresh Ed25519 keypair."""
|
||||||
|
private_key = Ed25519PrivateKey.generate()
|
||||||
|
seed = private_key.private_bytes_raw()
|
||||||
|
pubkey = private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
|
||||||
|
return base64.b64encode(seed).decode("ascii"), base64.b64encode(pubkey).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# sha256_file / build_checksums_text / generate_checksums
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_sha256_file_matches_hashlib(tmp_path):
|
||||||
|
f = tmp_path / "a.txt"
|
||||||
|
f.write_bytes(b"hello world")
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
assert sc.sha256_file(f) == hashlib.sha256(b"hello world").hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_checksums_text_sorted_and_formatted():
|
||||||
|
files = {"zeta.zip": "aa" * 32, "alpha.zip": "bb" * 32}
|
||||||
|
text = sc.build_checksums_text(files)
|
||||||
|
lines = text.splitlines()
|
||||||
|
assert lines[0].endswith("alpha.zip")
|
||||||
|
assert lines[1].endswith("zeta.zip")
|
||||||
|
# Standard sha256sum format: hash, two spaces, filename.
|
||||||
|
assert lines[0] == f"{'bb' * 32} alpha.zip"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_checksums_text_empty():
|
||||||
|
assert sc.build_checksums_text({}) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_checksums_from_directory(tmp_path):
|
||||||
|
(tmp_path / "b.bin").write_bytes(b"second")
|
||||||
|
(tmp_path / "a.bin").write_bytes(b"first")
|
||||||
|
(tmp_path / "subdir").mkdir()
|
||||||
|
(tmp_path / "subdir" / "ignored.bin").write_bytes(b"nested, not hashed")
|
||||||
|
|
||||||
|
text = sc.generate_checksums(tmp_path)
|
||||||
|
lines = text.splitlines()
|
||||||
|
assert len(lines) == 2
|
||||||
|
assert lines[0].endswith("a.bin")
|
||||||
|
assert lines[1].endswith("b.bin")
|
||||||
|
assert "subdir" not in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_checksums_excludes_manifest_files(tmp_path):
|
||||||
|
(tmp_path / "archive.zip").write_bytes(b"payload")
|
||||||
|
(tmp_path / "SHA256SUMS").write_text("stale")
|
||||||
|
(tmp_path / "SHA256SUMS.sig").write_bytes(b"stale-sig")
|
||||||
|
|
||||||
|
text = sc.generate_checksums(tmp_path, exclude={"SHA256SUMS", "SHA256SUMS.sig"})
|
||||||
|
assert "archive.zip" in text
|
||||||
|
assert "SHA256SUMS" not in text.replace("archive.zip", "")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# sign_checksums / verify_checksums
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_sign_then_verify_roundtrip():
|
||||||
|
seed_b64, pubkey_b64 = _make_keypair()
|
||||||
|
sums_text = "deadbeef" * 8 + " BetterClaudeConfig-Linux.tar.gz\n"
|
||||||
|
|
||||||
|
signature = sc.sign_checksums(seed_b64, sums_text)
|
||||||
|
assert len(signature) == 64
|
||||||
|
assert sc.verify_checksums(pubkey_b64, sums_text, signature) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_rejects_tampered_checksums():
|
||||||
|
seed_b64, pubkey_b64 = _make_keypair()
|
||||||
|
sums_text = "aa" * 32 + " file.zip\n"
|
||||||
|
signature = sc.sign_checksums(seed_b64, sums_text)
|
||||||
|
|
||||||
|
tampered = "bb" * 32 + " file.zip\n"
|
||||||
|
assert sc.verify_checksums(pubkey_b64, tampered, signature) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_rejects_wrong_key():
|
||||||
|
seed_b64, _ = _make_keypair()
|
||||||
|
_, other_pubkey_b64 = _make_keypair()
|
||||||
|
sums_text = "cc" * 32 + " file.zip\n"
|
||||||
|
signature = sc.sign_checksums(seed_b64, sums_text)
|
||||||
|
|
||||||
|
assert sc.verify_checksums(other_pubkey_b64, sums_text, signature) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_domain_prefix_is_applied():
|
||||||
|
"""The signed message must be prefixed, not the raw manifest bytes --
|
||||||
|
otherwise a signature over this manifest could be replayed as a
|
||||||
|
signature over an unrelated message with the same bytes elsewhere."""
|
||||||
|
seed_b64, pubkey_b64 = _make_keypair()
|
||||||
|
sums_text = "11" * 32 + " file.zip\n"
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey as PK
|
||||||
|
|
||||||
|
seed = base64.b64decode(seed_b64)
|
||||||
|
raw_signature = PK.from_private_bytes(seed).sign(sums_text.encode("utf-8"))
|
||||||
|
|
||||||
|
# A signature over the raw (unprefixed) bytes must NOT verify via our
|
||||||
|
# domain-separated verify function.
|
||||||
|
assert sc.verify_checksums(pubkey_b64, sums_text, raw_signature) is False
|
||||||
|
|
||||||
|
# But our own sign_checksums() output does verify.
|
||||||
|
good_signature = sc.sign_checksums(seed_b64, sums_text)
|
||||||
|
assert sc.verify_checksums(pubkey_b64, sums_text, good_signature) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_sign_checksums_rejects_bad_seed_length():
|
||||||
|
bad_seed_b64 = base64.b64encode(b"too-short").decode("ascii")
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
sc.sign_checksums(bad_seed_b64, "irrelevant\n")
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_checksums_rejects_bad_pubkey_length():
|
||||||
|
seed_b64, _ = _make_keypair()
|
||||||
|
sig = sc.sign_checksums(seed_b64, "irrelevant\n")
|
||||||
|
bad_pubkey_b64 = base64.b64encode(b"too-short").decode("ascii")
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
sc.verify_checksums(bad_pubkey_b64, "irrelevant\n", sig)
|
||||||
|
|
||||||
|
|
||||||
|
def test_public_key_b64_from_seed_matches_generated_pubkey():
|
||||||
|
seed_b64, pubkey_b64 = _make_keypair()
|
||||||
|
assert sc.public_key_b64_from_seed(seed_b64) == pubkey_b64
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# CLI (end-to-end, via subprocess so argparse wiring is exercised too)
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
SCRIPT = Path(__file__).resolve().parent.parent / "scripts" / "sign_checksums.py"
|
||||||
|
|
||||||
|
|
||||||
|
def _run(*args, env=None):
|
||||||
|
return subprocess.run(
|
||||||
|
[sys.executable, str(SCRIPT), *args],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_generate_sign_verify_roundtrip(tmp_path, monkeypatch):
|
||||||
|
seed_b64, pubkey_b64 = _make_keypair()
|
||||||
|
|
||||||
|
release_dir = tmp_path / "release-files"
|
||||||
|
release_dir.mkdir()
|
||||||
|
(release_dir / "BetterClaudeConfig-Linux.tar.gz").write_bytes(b"fake archive contents")
|
||||||
|
(release_dir / "BetterClaudeConfig-macOS.zip").write_bytes(b"fake zip contents")
|
||||||
|
|
||||||
|
sums_path = release_dir / "SHA256SUMS"
|
||||||
|
sig_path = release_dir / "SHA256SUMS.sig"
|
||||||
|
|
||||||
|
gen = _run("generate", str(release_dir), "--out", str(sums_path))
|
||||||
|
assert gen.returncode == 0, gen.stderr
|
||||||
|
assert sums_path.exists()
|
||||||
|
body = sums_path.read_text()
|
||||||
|
assert "BetterClaudeConfig-Linux.tar.gz" in body
|
||||||
|
assert "BetterClaudeConfig-macOS.zip" in body
|
||||||
|
|
||||||
|
sign = _run("sign", "--sums", str(sums_path), "--out", str(sig_path), "--key-b64", seed_b64)
|
||||||
|
assert sign.returncode == 0, sign.stderr
|
||||||
|
assert sig_path.exists()
|
||||||
|
assert sig_path.stat().st_size == 64
|
||||||
|
|
||||||
|
verify = _run(
|
||||||
|
"verify",
|
||||||
|
"--sums",
|
||||||
|
str(sums_path),
|
||||||
|
"--sig",
|
||||||
|
str(sig_path),
|
||||||
|
"--pubkey-b64",
|
||||||
|
pubkey_b64,
|
||||||
|
)
|
||||||
|
assert verify.returncode == 0, verify.stderr
|
||||||
|
assert "OK" in verify.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_sign_without_key_fails_loudly(tmp_path):
|
||||||
|
sums_path = tmp_path / "SHA256SUMS"
|
||||||
|
sums_path.write_text("aa" * 32 + " file.zip\n")
|
||||||
|
sig_path = tmp_path / "SHA256SUMS.sig"
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
env = {k: v for k, v in os.environ.items() if k != "RELEASE_SIGNING_KEY"}
|
||||||
|
result = _run("sign", "--sums", str(sums_path), "--out", str(sig_path), env=env)
|
||||||
|
|
||||||
|
assert result.returncode != 0
|
||||||
|
assert not sig_path.exists(), "must never write a bogus/empty signature file"
|
||||||
|
assert "no signing key" in result.stderr.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_verify_detects_tampering(tmp_path):
|
||||||
|
seed_b64, pubkey_b64 = _make_keypair()
|
||||||
|
sums_path = tmp_path / "SHA256SUMS"
|
||||||
|
sums_path.write_text("aa" * 32 + " file.zip\n")
|
||||||
|
sig_path = tmp_path / "SHA256SUMS.sig"
|
||||||
|
|
||||||
|
_run("sign", "--sums", str(sums_path), "--out", str(sig_path), "--key-b64", seed_b64)
|
||||||
|
|
||||||
|
sums_path.write_text("bb" * 32 + " file.zip\n") # tamper after signing
|
||||||
|
verify = _run(
|
||||||
|
"verify",
|
||||||
|
"--sums",
|
||||||
|
str(sums_path),
|
||||||
|
"--sig",
|
||||||
|
str(sig_path),
|
||||||
|
"--pubkey-b64",
|
||||||
|
pubkey_b64,
|
||||||
|
)
|
||||||
|
assert verify.returncode != 0
|
||||||
|
assert "FAILED" in verify.stdout + verify.stderr
|
||||||
Reference in New Issue
Block a user