#!/usr/bin/env python3 """Sign Sparkle appcast enclosures with a Google Cloud KMS Ed25519 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" def run(command: list[str]) -> None: subprocess.run(command, check=True) def kms_sign(args: argparse.Namespace, payload: Path) -> bytes: 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 signature_path.read_bytes() 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) signature = kms_sign(args, artifact) enclosure.attrib[f"{{{SPARKLE_NS}}}edSignature"] = base64.b64encode(signature).decode("ascii") 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("--gcloud", default=os.environ.get("GCLOUD", "gcloud")) 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:]))