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
149 lines
5.6 KiB
Python
Executable file
149 lines
5.6 KiB
Python
Executable file
#!/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 sign directly from the decrypted release seed.
|
|
"""
|
|
|
|
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 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"
|
|
)
|
|
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_with_release_seed(args, payload)
|
|
if payload.stat().st_size <= args.kms_max_input_bytes:
|
|
return kms_sign(args, payload)
|
|
return sign_with_release_seed(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"))
|
|
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:]))
|