#!/usr/bin/env python3 """Sign Sparkle appcast enclosures. 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. """ from __future__ import annotations import argparse import base64 import os import subprocess import sys import tempfile import urllib.parse import xml.etree.ElementTree as ET from pathlib import Path SPARKLE_NS = "http://www.andymatuschak.org/xml-namespaces/sparkle" DEFAULT_PROJECT = "project-88c23ce9-918a-470a-b33" DEFAULT_LOCATION = "global" DEFAULT_KEYRING = "burrow-identity" DEFAULT_KEY = "sparkle-ed25519" DEFAULT_VERSION = "1" DEFAULT_KMS_MAX_INPUT_BYTES = 65536 def run(command: list[str]) -> subprocess.CompletedProcess[str]: return subprocess.run(command, check=True, text=True, stdout=subprocess.PIPE) def kms_sign(args: argparse.Namespace, payload: Path) -> str: with tempfile.TemporaryDirectory(prefix="burrow-sparkle-kms.") as tmp: signature_path = Path(tmp) / "signature.bin" command = [ args.gcloud, "kms", "asymmetric-sign", "--location", args.kms_location, "--keyring", args.kms_keyring, "--key", args.kms_key, "--version", args.kms_version, "--input-file", str(payload), "--signature-file", str(signature_path), ] if args.gcloud_project: command.extend(["--project", args.gcloud_project]) run(command) return base64.b64encode(signature_path.read_bytes()).decode("ascii") def sign_update(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 def sparkle_signature(args: argparse.Namespace, payload: Path) -> str: if payload.stat().st_size <= args.kms_max_input_bytes: return kms_sign(args, payload) return sign_update(args, payload) def enclosure_path(enclosure: ET.Element, artifact_dir: Path) -> Path: url = enclosure.attrib.get("url") if not url: raise ValueError("Sparkle enclosure is missing a url attribute") parsed = urllib.parse.urlparse(url) name = Path(parsed.path).name if not name: raise ValueError(f"Sparkle enclosure URL has no file name: {url}") path = artifact_dir / name if not path.is_file(): raise FileNotFoundError(f"Sparkle enclosure artifact not found: {path}") return path def sign_appcast(args: argparse.Namespace) -> None: appcast = Path(args.appcast) artifact_dir = Path(args.artifact_dir) if args.artifact_dir else appcast.parent ET.register_namespace("sparkle", SPARKLE_NS) tree = ET.parse(appcast) root = tree.getroot() enclosures = root.findall(".//enclosure") if not enclosures: raise ValueError(f"no Sparkle enclosures found in {appcast}") for enclosure in enclosures: artifact = enclosure_path(enclosure, artifact_dir) enclosure.attrib[f"{{{SPARKLE_NS}}}edSignature"] = sparkle_signature(args, artifact) tree.write(appcast, encoding="utf-8", xml_declaration=True) print(f"signed {len(enclosures)} enclosure(s) in {appcast}") def parse_args(argv: list[str]) -> argparse.Namespace: parser = argparse.ArgumentParser(description="Sign Sparkle appcast enclosures with Google Cloud KMS.") parser.add_argument("--appcast", required=True) parser.add_argument("--artifact-dir") parser.add_argument("--gcloud-project", default=os.environ.get("GOOGLE_CLOUD_PROJECT") or os.environ.get("BURROW_GOOGLE_PROJECT_ID") or DEFAULT_PROJECT) parser.add_argument("--kms-location", default=os.environ.get("BURROW_KMS_LOCATION", DEFAULT_LOCATION)) parser.add_argument("--kms-keyring", default=os.environ.get("BURROW_KMS_KEYRING", DEFAULT_KEYRING)) parser.add_argument("--kms-key", default=os.environ.get("BURROW_SPARKLE_KMS_KEY", DEFAULT_KEY)) parser.add_argument("--kms-version", default=os.environ.get("BURROW_SPARKLE_KMS_VERSION", DEFAULT_VERSION)) 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) def main(argv: list[str]) -> int: sign_appcast(parse_args(argv)) return 0 if __name__ == "__main__": raise SystemExit(main(sys.argv[1:]))