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 #!/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 from __future__ import annotations
@ -20,13 +25,14 @@ DEFAULT_LOCATION = "global"
DEFAULT_KEYRING = "burrow-identity" DEFAULT_KEYRING = "burrow-identity"
DEFAULT_KEY = "sparkle-ed25519" DEFAULT_KEY = "sparkle-ed25519"
DEFAULT_VERSION = "1" DEFAULT_VERSION = "1"
DEFAULT_KMS_MAX_INPUT_BYTES = 65536
def run(command: list[str]) -> None: def run(command: list[str]) -> subprocess.CompletedProcess[str]:
subprocess.run(command, check=True) 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: with tempfile.TemporaryDirectory(prefix="burrow-sparkle-kms.") as tmp:
signature_path = Path(tmp) / "signature.bin" signature_path = Path(tmp) / "signature.bin"
command = [ command = [
@ -49,7 +55,33 @@ def kms_sign(args: argparse.Namespace, payload: Path) -> bytes:
if args.gcloud_project: if args.gcloud_project:
command.extend(["--project", args.gcloud_project]) command.extend(["--project", args.gcloud_project])
run(command) 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: 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: for enclosure in enclosures:
artifact = enclosure_path(enclosure, artifact_dir) artifact = enclosure_path(enclosure, artifact_dir)
signature = kms_sign(args, artifact) enclosure.attrib[f"{{{SPARKLE_NS}}}edSignature"] = sparkle_signature(args, artifact)
enclosure.attrib[f"{{{SPARKLE_NS}}}edSignature"] = base64.b64encode(signature).decode("ascii")
tree.write(appcast, encoding="utf-8", xml_declaration=True) tree.write(appcast, encoding="utf-8", xml_declaration=True)
print(f"signed {len(enclosures)} enclosure(s) in {appcast}") 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-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-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-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("--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) return parser.parse_args(argv)

View file

@ -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 with the same name are deleted and recreated if Apple reports that they contain
a different distribution certificate. a different distribution certificate.
Sparkle appcast signing uses the non-exportable `sparkle-ed25519` Google KMS Sparkle appcast signing keeps the `sparkle-ed25519` Google KMS key available
key. macOS release builds embed public EdDSA key 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 `Myv9ZNZT6YGKMtMezh52ra4WqaeEKc4VlvVU0evhJeI=` and point Sparkle metadata at
`https://releases.burrow.net/sparkle/appcast.xml`. The appcast signer runs only `https://releases.burrow.net/sparkle/appcast.xml`. The appcast signer runs only
when `BURROW_SPARKLE_SIGN_WITH_KMS=true` so unsigned local validation artifacts when `BURROW_SPARKLE_SIGN_WITH_KMS=true` so unsigned local validation artifacts

View file

@ -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. 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 - The signed iOS IPA is uploaded to App Store Connect, and the signed macOS
ZIP is used as the Sparkle tester artifact. ZIP is used as the Sparkle tester artifact.
- Add a non-exportable Google KMS key named `sparkle-ed25519` for Sparkle - Add a Google KMS key named `sparkle-ed25519` for small Sparkle signing probes
appcast signatures. It uses `EC_SIGN_ED25519` with software protection level and future signing-service work. It uses `EC_SIGN_ED25519` with software
because Google KMS rejects Ed25519 for HSM protection. macOS builds embed protection level because Google KMS rejects Ed25519 for HSM protection. Google
public EdDSA key `Myv9ZNZT6YGKMtMezh52ra4WqaeEKc4VlvVU0evhJeI=`. 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: - Use Namespace labels for platform lanes:
- `namespace-profile-linux-medium` - `namespace-profile-linux-medium`
- `namespace-profile-macos-large` - `namespace-profile-macos-large`

View file

@ -81,10 +81,13 @@ Its public key matches KMS key version
## Sparkle Appcast Signing ## Sparkle Appcast Signing
Sparkle appcasts use `sparkle-ed25519`, a non-exportable Google KMS key with Sparkle appcasts keep `sparkle-ed25519`, a Google KMS key with algorithm
algorithm `EC_SIGN_ED25519` and software protection level. Google KMS rejects `EC_SIGN_ED25519` and software protection level, for small signing probes and
Ed25519 at HSM protection level, so this key is intentionally separate from the future signing-service work. Google KMS rejects Ed25519 at HSM protection level
HSM RSA keys used for Apple certificate CSRs and repository signing. 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: The public EdDSA key embedded in macOS release builds is: