Sign Sparkle appcasts in Python
Some checks failed
Cache: Publish Nix / Publish Nix Cache (push) Successful in 3m4s
Build Rust / Cargo Test (push) Successful in 4m5s
Build Site / Next.js Build (push) Failing after 4s
Infra: OpenTofu / OpenTofu (grafana) (push) Successful in 4s
Lint Governance / BEP Metadata (push) Successful in 1s

This commit is contained in:
Conrad Kramer 2026-06-07 16:56:18 -07:00
parent 1ef6fedeaf
commit 640d4693ac
5 changed files with 35 additions and 26 deletions

View file

@ -3,7 +3,7 @@
Google Cloud KMS Ed25519 signing can only sign small payloads directly. Sparkle
requires a standard EdDSA signature over the full update archive, so normal
release artifacts use Sparkle's signing tool with the decrypted release key.
release artifacts sign directly from the decrypted release seed.
"""
from __future__ import annotations
@ -58,32 +58,39 @@ def kms_sign(args: argparse.Namespace, payload: Path) -> str:
return base64.b64encode(signature_path.read_bytes()).decode("ascii")
def sign_update(args: argparse.Namespace, payload: Path) -> str:
def read_sparkle_seed(key_path: Path) -> bytes:
key_data = key_path.read_bytes().strip()
if len(key_data) == 32:
return key_data
try:
decoded = base64.b64decode(key_data, validate=True)
except ValueError as exc:
raise ValueError(f"Sparkle key is not raw or base64 Ed25519 seed data: {key_path}") from exc
if len(decoded) != 32:
raise ValueError(f"Sparkle key must decode to a 32-byte Ed25519 seed: {key_path}")
return decoded
def sign_with_release_seed(args: argparse.Namespace, payload: Path) -> str:
key_path = Path(args.ed_key_path) if args.ed_key_path else None
if key_path is None or not key_path.is_file():
raise FileNotFoundError(
"Sparkle artifact is too large for direct Google KMS Ed25519 signing "
"and SPARKLE_EDDSA_KEY_PATH does not point to a key file"
)
result = run([
args.sign_update,
"--ed-key-file",
str(key_path),
"-p",
str(payload),
])
signature = result.stdout.strip().splitlines()[-1].strip()
if not signature:
raise ValueError(f"Sparkle sign_update produced no signature for {payload}")
return signature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
seed = read_sparkle_seed(key_path)
signature = Ed25519PrivateKey.from_private_bytes(seed).sign(payload.read_bytes())
return base64.b64encode(signature).decode("ascii")
def sparkle_signature(args: argparse.Namespace, payload: Path) -> str:
if args.ed_key_path:
return sign_update(args, payload)
return sign_with_release_seed(args, payload)
if payload.stat().st_size <= args.kms_max_input_bytes:
return kms_sign(args, payload)
return sign_update(args, payload)
return sign_with_release_seed(args, payload)
def enclosure_path(enclosure: ET.Element, artifact_dir: Path) -> Path:
@ -130,7 +137,6 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
parser.add_argument("--kms-max-input-bytes", type=int, default=DEFAULT_KMS_MAX_INPUT_BYTES)
parser.add_argument("--gcloud", default=os.environ.get("GCLOUD", "gcloud"))
parser.add_argument("--ed-key-path", default=os.environ.get("SPARKLE_EDDSA_KEY_PATH"))
parser.add_argument("--sign-update", default=os.environ.get("SPARKLE_SIGN_UPDATE", "sign_update"))
return parser.parse_args(argv)