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 Google Cloud KMS Ed25519 signing can only sign small payloads directly. Sparkle
requires a standard EdDSA signature over the full update archive, so normal 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 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") 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 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(): if key_path is None or not key_path.is_file():
raise FileNotFoundError( raise FileNotFoundError(
"Sparkle artifact is too large for direct Google KMS Ed25519 signing " "Sparkle artifact is too large for direct Google KMS Ed25519 signing "
"and SPARKLE_EDDSA_KEY_PATH does not point to a key file" "and SPARKLE_EDDSA_KEY_PATH does not point to a key file"
) )
result = run([ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
args.sign_update,
"--ed-key-file", seed = read_sparkle_seed(key_path)
str(key_path), signature = Ed25519PrivateKey.from_private_bytes(seed).sign(payload.read_bytes())
"-p", return base64.b64encode(signature).decode("ascii")
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: def sparkle_signature(args: argparse.Namespace, payload: Path) -> str:
if args.ed_key_path: 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: if payload.stat().st_size <= args.kms_max_input_bytes:
return kms_sign(args, payload) 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: 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("--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("--gcloud", default=os.environ.get("GCLOUD", "gcloud"))
parser.add_argument("--ed-key-path", default=os.environ.get("SPARKLE_EDDSA_KEY_PATH")) 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) return parser.parse_args(argv)

View file

@ -33,8 +33,8 @@ a different distribution certificate.
Sparkle appcast signing keeps the `sparkle-ed25519` Google KMS key available Sparkle appcast signing keeps the `sparkle-ed25519` Google KMS key available
for payloads small enough for direct KMS Ed25519 signing, but normal macOS for payloads small enough for direct KMS Ed25519 signing, but normal macOS
release archives are larger than Google KMS accepts for direct Ed25519 release archives are larger than Google KMS accepts for direct Ed25519
messages. Those archives are signed with Sparkle's `sign_update` using the messages. Those archives are signed directly from the decrypted release
decrypted release `SPARKLE_EDDSA_KEY_PATH` so Sparkle receives the standard `SPARKLE_EDDSA_KEY_PATH` seed so Sparkle receives the standard
full-archive EdDSA signature it verifies at update time. macOS release builds full-archive EdDSA signature it verifies at update time. macOS release builds
embed public EdDSA key embed public EdDSA key
`uugZuJeqvvKd91NZ6F1Fv2cQenUbIG/ZW3L9MuaEz30=` and point Sparkle metadata at `uugZuJeqvvKd91NZ6F1Fv2cQenUbIG/ZW3L9MuaEz30=` and point Sparkle metadata at

View file

@ -145,10 +145,10 @@ The same change also makes the forge itself the release authority: release tags
and future signing-service work. It uses `EC_SIGN_ED25519` with software and future signing-service work. It uses `EC_SIGN_ED25519` with software
protection level because Google KMS rejects Ed25519 for HSM protection. Google protection level because Google KMS rejects Ed25519 for HSM protection. Google
KMS also rejects normal macOS release archives as direct Ed25519 messages, so KMS also rejects normal macOS release archives as direct Ed25519 messages, so
the tester pipeline signs full-size Sparkle archives with Sparkle's the tester pipeline signs full-size Sparkle archives directly from the
`sign_update` and the decrypted `SPARKLE_EDDSA_KEY_PATH` release secret until decrypted `SPARKLE_EDDSA_KEY_PATH` release seed until a compatible KMS-backed
a compatible KMS-backed Sparkle signing service is introduced. macOS builds Sparkle signing service is introduced. macOS builds embed public EdDSA key
embed public EdDSA key `uugZuJeqvvKd91NZ6F1Fv2cQenUbIG/ZW3L9MuaEz30=`. `uugZuJeqvvKd91NZ6F1Fv2cQenUbIG/ZW3L9MuaEz30=`.
- Use Namespace labels for platform lanes: - Use Namespace labels for platform lanes:
- `namespace-profile-linux-medium` - `namespace-profile-linux-medium`
- `namespace-profile-macos-large` - `namespace-profile-macos-large`

View file

@ -61,6 +61,9 @@
pkgs.nodejs pkgs.nodejs
else else
pkgs.nodejs_20; pkgs.nodejs_20;
python3WithCrypto = pkgs.python3.withPackages (pythonPackages: [
pythonPackages.cryptography
]);
optionalPackage = name: lib.optional (lib.hasAttr name pkgs) (lib.getAttr name pkgs); optionalPackage = name: lib.optional (lib.hasAttr name pkgs) (lib.getAttr name pkgs);
googleKmsPkcs11Package = googleKmsPkcs11Package =
if pkgs.stdenv.hostPlatform.isx86_64 && pkgs.stdenv.isLinux then if pkgs.stdenv.hostPlatform.isx86_64 && pkgs.stdenv.isLinux then
@ -137,7 +140,7 @@ EOF
curl curl
jq jq
nodejsPkg nodejsPkg
python3 python3WithCrypto
openssl openssl
rsync rsync
zip zip

View file

@ -85,9 +85,9 @@ Sparkle appcasts keep `sparkle-ed25519`, a Google KMS key with algorithm
`EC_SIGN_ED25519` and software protection level, for small signing probes and `EC_SIGN_ED25519` and software protection level, for small signing probes and
future signing-service work. Google KMS rejects Ed25519 at HSM protection level future signing-service work. Google KMS rejects Ed25519 at HSM protection level
and also rejects full-size macOS release archives as direct Ed25519 messages. and also rejects full-size macOS release archives as direct Ed25519 messages.
The tester pipeline therefore signs normal Sparkle archives with Sparkle's The tester pipeline therefore signs normal Sparkle archives directly from the
`sign_update` and the decrypted `SPARKLE_EDDSA_KEY_PATH` release secret so the decrypted `SPARKLE_EDDSA_KEY_PATH` release seed so the appcast contains the
appcast contains the standard full-archive EdDSA signature Sparkle verifies. standard full-archive EdDSA signature Sparkle verifies.
The public EdDSA key embedded in macOS release builds is: The public EdDSA key embedded in macOS release builds is: