Use Sparkle signer for full appcasts
Some checks failed
Build Rust / Cargo Test (push) Successful in 4m1s
Build Site / Next.js Build (push) Failing after 5s
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:13:53 -07:00
parent a84922f4f3
commit 1d45016a63
4 changed files with 63 additions and 17 deletions

View file

@ -1,5 +1,10 @@
#!/usr/bin/env python3
"""Sign Sparkle appcast enclosures with a Google Cloud KMS Ed25519 key."""
"""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
@ -20,13 +25,14 @@ 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]) -> None:
subprocess.run(command, check=True)
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) -> bytes:
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 = [
@ -49,7 +55,33 @@ def kms_sign(args: argparse.Namespace, payload: Path) -> bytes:
if args.gcloud_project:
command.extend(["--project", args.gcloud_project])
run(command)
return base64.b64decode(signature_path.read_text(encoding="utf-8").strip())
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:
@ -78,8 +110,7 @@ def sign_appcast(args: argparse.Namespace) -> None:
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")
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}")
@ -94,7 +125,10 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
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)