diff --git a/Scripts/sparkle/sign-appcast-kms.py b/Scripts/sparkle/sign-appcast-kms.py index a53c211..03d28b3 100755 --- a/Scripts/sparkle/sign-appcast-kms.py +++ b/Scripts/sparkle/sign-appcast-kms.py @@ -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) diff --git a/docs/DISTRIBUTION.md b/docs/DISTRIBUTION.md index b522e96..9c6b7ae 100644 --- a/docs/DISTRIBUTION.md +++ b/docs/DISTRIBUTION.md @@ -30,8 +30,13 @@ pins iOS App Store profiles to that certificate. Existing App Store profiles with the same name are deleted and recreated if Apple reports that they contain a different distribution certificate. -Sparkle appcast signing uses the non-exportable `sparkle-ed25519` Google KMS -key. macOS release builds embed public EdDSA key +Sparkle appcast signing keeps the `sparkle-ed25519` Google KMS key available +for payloads small enough for direct KMS Ed25519 signing, but normal macOS +release archives are larger than Google KMS accepts for direct Ed25519 +messages. Those archives are signed with Sparkle's `sign_update` using the +decrypted release `SPARKLE_EDDSA_KEY_PATH` so Sparkle receives the standard +full-archive EdDSA signature it verifies at update time. macOS release builds +embed public EdDSA key `Myv9ZNZT6YGKMtMezh52ra4WqaeEKc4VlvVU0evhJeI=` and point Sparkle metadata at `https://releases.burrow.net/sparkle/appcast.xml`. The appcast signer runs only when `BURROW_SPARKLE_SIGN_WITH_KMS=true` so unsigned local validation artifacts diff --git a/evolution/proposals/BEP-0009-release-infrastructure-and-store-upload-pipeline.md b/evolution/proposals/BEP-0009-release-infrastructure-and-store-upload-pipeline.md index 51b55e7..3458875 100644 --- a/evolution/proposals/BEP-0009-release-infrastructure-and-store-upload-pipeline.md +++ b/evolution/proposals/BEP-0009-release-infrastructure-and-store-upload-pipeline.md @@ -141,10 +141,14 @@ The same change also makes the forge itself the release authority: release tags sign the app/extension bundles with the patched `rcodesign` PKCS#11 path. - The signed iOS IPA is uploaded to App Store Connect, and the signed macOS ZIP is used as the Sparkle tester artifact. -- Add a non-exportable Google KMS key named `sparkle-ed25519` for Sparkle - appcast signatures. It uses `EC_SIGN_ED25519` with software protection level - because Google KMS rejects Ed25519 for HSM protection. macOS builds embed - public EdDSA key `Myv9ZNZT6YGKMtMezh52ra4WqaeEKc4VlvVU0evhJeI=`. +- Add a Google KMS key named `sparkle-ed25519` for small Sparkle signing probes + and future signing-service work. It uses `EC_SIGN_ED25519` with software + protection level because Google KMS rejects Ed25519 for HSM protection. Google + KMS also rejects normal macOS release archives as direct Ed25519 messages, so + the tester pipeline signs full-size Sparkle archives with Sparkle's + `sign_update` and the decrypted `SPARKLE_EDDSA_KEY_PATH` release secret until + a compatible KMS-backed Sparkle signing service is introduced. macOS builds + embed public EdDSA key `Myv9ZNZT6YGKMtMezh52ra4WqaeEKc4VlvVU0evhJeI=`. - Use Namespace labels for platform lanes: - `namespace-profile-linux-medium` - `namespace-profile-macos-large` diff --git a/infra/identity/README.md b/infra/identity/README.md index cad3fdb..21b74da 100644 --- a/infra/identity/README.md +++ b/infra/identity/README.md @@ -81,10 +81,13 @@ Its public key matches KMS key version ## Sparkle Appcast Signing -Sparkle appcasts use `sparkle-ed25519`, a non-exportable Google KMS key with -algorithm `EC_SIGN_ED25519` and software protection level. Google KMS rejects -Ed25519 at HSM protection level, so this key is intentionally separate from the -HSM RSA keys used for Apple certificate CSRs and repository signing. +Sparkle appcasts keep `sparkle-ed25519`, a Google KMS key with algorithm +`EC_SIGN_ED25519` and software protection level, for small signing probes and +future signing-service work. Google KMS rejects Ed25519 at HSM protection level +and also rejects full-size macOS release archives as direct Ed25519 messages. +The tester pipeline therefore signs normal Sparkle archives with Sparkle's +`sign_update` and the decrypted `SPARKLE_EDDSA_KEY_PATH` release secret so the +appcast contains the standard full-archive EdDSA signature Sparkle verifies. The public EdDSA key embedded in macOS release builds is: