From 2a847a0302152223fa1dcbb90f62c3d9f57c49ad Mon Sep 17 00:00:00 2001 From: sudoHG Date: Thu, 5 Nov 2026 16:53:43 +0800 Subject: [PATCH 1/3] Publish a signed appcast with each release scripts/generate-appcast.py writes one Sparkle item for the notarized DMG: sparkle:version and shortVersionString, minimumSystemVersion 14.0.0, pubDate, the versioned GitHub enclosure with length and edSignature, embedded Markdown release notes in English and Simplified Chinese (xml:lang), and a fullReleaseNotesLink to the GitHub Release. --critical adds sparkle:criticalUpdate. It signs the DMG and then the feed with sign_update from the resolved SwiftPM artifact and verifies both before writing; sign_update output is never shown. package-release.sh requires --sparkle-key-credential and --release-notes when notarizing, checks them and CFBundleVersion before any notarization request, runs the generator through one askkey run --wait-for-approval request with the key delivered as SPARKLE_ED_KEY_FILE, and adds appcast.xml to the output names under the existing conflict checks. --no-notarize runs write no appcast and reject the appcast options. site/_redirects sends /appcast.xml to the latest release's appcast with a 302. ADR 0006, ADR 0011 (Publication), docs/testing.md, site-src/README.md and CHANGELOG.md describe publication, the read-back, critical updates, pulling a bad update and the first-update check. Closes #259 --- CHANGELOG.md | 2 + .../Fixtures/package-release-tool.py | 26 ++- Tests/Automation/test_generate_appcast.py | 167 +++++++++++++++++ Tests/Automation/test_package_release.py | 45 ++++- .../test_package_release_appcast.py | 133 ++++++++++++++ docs/adr/0006-release-process.md | 8 +- docs/adr/0011-in-app-updates.md | 4 + docs/testing.md | 33 +++- scripts/generate-appcast.py | 168 ++++++++++++++++++ scripts/package-release.sh | 56 +++++- site-src/README.md | 2 + site/_redirects | 1 + 12 files changed, 620 insertions(+), 25 deletions(-) create mode 100644 Tests/Automation/test_generate_appcast.py create mode 100644 Tests/Automation/test_package_release_appcast.py create mode 100644 scripts/generate-appcast.py create mode 100644 site/_redirects diff --git a/CHANGELOG.md b/CHANGELOG.md index 67b0e75..2a61fc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,13 @@ Notable changes to AskKey are documented here, grouped by release and change typ ### Added - The official Release install at `/Applications/Ask Key.app` can check for updates with Sparkle 2. Settings → General has **Check for Updates Automatically** and **Check for Updates…**, and the menu bar icon's right-click menu has **Check for Updates…**. Sparkle asks on the second launch whether to check automatically, once a day. Development, `Local` and test builds have no updater. +- Each release publishes a signed update feed, `appcast.xml`, as a GitHub Release asset, and `https://askkey.sudohg.com/appcast.xml` redirects to the one on the latest release. The update window shows the release notes in English or Simplified Chinese, following the macOS language. ### Security - Every update needs the user's confirmation; nothing installs silently. The appcast and the update archive must carry valid EdDSA signatures. The relaunch waits until no approval prompt, unused approval, running request, open upload or authentication prompt remains, and then locks Ask Key and ends timed allowances. - Update checks are Ask Key's only outbound network requests. They go to `askkey.sudohg.com` through Cloudflare and redirect to GitHub, and downloads come from GitHub; both see the IP address, the app version and preferred languages. No system profile is sent. See [ADR 0011](docs/adr/0011-in-app-updates.md). +- Release notes are embedded in the signed feed, so showing them fetches nothing else. Security fixes are marked as critical updates. ### Known limitations diff --git a/Tests/Automation/Fixtures/package-release-tool.py b/Tests/Automation/Fixtures/package-release-tool.py index 5d4333b..0de8b23 100644 --- a/Tests/Automation/Fixtures/package-release-tool.py +++ b/Tests/Automation/Fixtures/package-release-tool.py @@ -81,7 +81,8 @@ print("source=" + ("Developer ID" if failure == "app-source" else "Notarized Developer ID")) elif name == "askkey": assert arguments[0] == "run" and "--" in arguments - if failure == "approval-denied": + sparkle = arguments[arguments.index("--credential") + 1] == "synthetic-sparkle-key" + if failure == ("sparkle-approval-denied" if sparkle else "approval-denied"): sys.exit(1) # Pass test control data through a local fixture file, not the target env. configuration_path.write_text(json.dumps({key: os.environ[key] for key in ( @@ -89,15 +90,22 @@ environment = {key: value for key, value in os.environ.items() if key in ("HOME", "PATH", "TMPDIR", "LANG", "TERM", "SHELL", "USER", "LOGNAME") or key.startswith("LC_")} - for key, value in (("KEY_ID", "synthetic-key-id"), ("ISSUER_ID", "synthetic-issuer-id")): - assert key not in environment - environment[key] = value - assert "PRIVATE_KEY_FILE" not in environment + if not sparkle: + for key, value in (("KEY_ID", "synthetic-key-id"), ("ISSUER_ID", "synthetic-issuer-id")): + assert key not in environment + environment[key] = value + variable = "SPARKLE_ED_KEY_FILE" if sparkle else "PRIVATE_KEY_FILE" + assert variable not in environment key_root, = Path(environment["TMPDIR"]).glob("askkey-package-release.*") - with tempfile.TemporaryDirectory(prefix="synthetic-key-", dir=key_root) as directory: - key_file = Path(directory) / "key.p8" - key_file.write_bytes(b"synthetic-private-key-bytes") - environment["PRIVATE_KEY_FILE"] = str(key_file) + prefix = "synthetic-sparkle-key-" if sparkle else "synthetic-key-" + with tempfile.TemporaryDirectory(prefix=prefix, dir=key_root) as directory: + key_file = Path(directory) / ("key" if sparkle else "key.p8") + if sparkle: + # The test's synthetic Ed25519 seed file; no keychain item exists. + key_file.write_bytes(Path(os.environ["PACKAGE_TEST_SPARKLE_KEY"]).read_bytes()) + else: + key_file.write_bytes(b"synthetic-private-key-bytes") + environment[variable] = str(key_file) if failure == "delivery-missing": key_file.unlink() result = subprocess.call(arguments[arguments.index("--") + 1:], env=environment) diff --git a/Tests/Automation/test_generate_appcast.py b/Tests/Automation/test_generate_appcast.py new file mode 100644 index 0000000..41dd8d5 --- /dev/null +++ b/Tests/Automation/test_generate_appcast.py @@ -0,0 +1,167 @@ +"""Check the signed Sparkle appcast with synthetic Ed25519 keys and the pinned sign_update.""" + +import base64 +from email.utils import parsedate_to_datetime +import os +from pathlib import Path +import subprocess +import tempfile +import unittest +import xml.etree.ElementTree as ET + +ROOT = Path(__file__).resolve().parents[2] +SCRIPT = ROOT / "scripts/generate-appcast.py" +SIGN_UPDATE = ROOT / ".build/artifacts/sparkle/Sparkle/bin/sign_update" +SPARKLE = "{http://www.andymatuschak.org/xml-namespaces/sparkle}" +XML_LANG = "{http://www.w3.org/XML/1998/namespace}lang" +VERSION = "9.8.7" +ENGLISH_NOTES = "### Added\n\n- Synthetic **English** notes &
." +CHINESE_NOTES = "### 新增\n\n- 合成的简体中文说明。" # i18n-literal: synthetic Simplified Chinese release notes +RELEASE_NOTES = f"\n{ENGLISH_NOTES}\n\n\n{CHINESE_NOTES}\n" +SIGN_UPDATE_REQUIRED = "needs the resolved Sparkle artifact; run `swift package resolve`" + + +def write_synthetic_key(path): + """Write a random Ed25519 seed file the way Sparkle exports one; no keychain is involved.""" + path.write_text(base64.b64encode(os.urandom(32)).decode("ascii") + "\n", encoding="ascii") + return path + + +def verify(path, key, signature=None): + arguments = [str(SIGN_UPDATE), "--verify", "--ed-key-file", str(key), str(path)] + if signature is not None: + arguments.append(signature) + return subprocess.run(arguments, stdin=subprocess.DEVNULL, capture_output=True).returncode == 0 + + +@unittest.skipUnless(SIGN_UPDATE.is_file(), SIGN_UPDATE_REQUIRED) +class GenerateAppcastTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory(prefix="askkey-appcast-test-") + self.addCleanup(temporary.cleanup) + self.directory = Path(temporary.name) + self.scratch = self.directory / "scratch" + self.scratch.mkdir() + self.key = write_synthetic_key(self.directory / "sparkle-key") + self.dmg = self.directory / f"AskKey-{VERSION}.dmg" + self.dmg.write_bytes(os.urandom(4096) + b"synthetic disk image") + self.notes = self.directory / "notes.md" + self.notes.write_text(RELEASE_NOTES, encoding="utf-8") + self.output = self.directory / "appcast.xml" + + def run_generator(self, *extra, key=None, notes=None, dmg=None): + environment = {key: value for key, value in os.environ.items() if not key.startswith("ASKKEY_")} + environment["TMPDIR"] = str(self.scratch) + return subprocess.run( + ["python3", str(SCRIPT), str(dmg or self.dmg), "--version", VERSION, + "--release-notes", str(notes or self.notes), "--ed-key-file", str(key or self.key), + "--output", str(self.output), *extra], + env=environment, cwd=self.directory, text=True, capture_output=True, timeout=60) + + def item(self): + channel = ET.parse(self.output).getroot().find("channel") + items = channel.findall("item") + self.assertEqual(len(items), 1) + return items[0] + + def test_synthetic_key_produces_a_feed_that_sign_update_verifies(self): + result = self.run_generator() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout + result.stderr, "") + self.assertTrue(verify(self.output, self.key)) + item = self.item() + self.assertEqual(item.findtext(SPARKLE + "version"), VERSION) + self.assertEqual(item.findtext(SPARKLE + "shortVersionString"), VERSION) + self.assertEqual(item.findtext(SPARKLE + "minimumSystemVersion"), "14.0.0") + self.assertIsNotNone(parsedate_to_datetime(item.findtext("pubDate")).tzinfo) + self.assertEqual(item.findtext(SPARKLE + "fullReleaseNotesLink"), + f"https://github.com/sudoHG/AskKey/releases/tag/v{VERSION}") + self.assertIsNone(item.find(SPARKLE + "releaseNotesLink")) + self.assertIsNone(item.find(SPARKLE + "criticalUpdate")) + descriptions = {element.get(XML_LANG): (element.get(SPARKLE + "format"), element.text) + for element in item.findall("description")} + self.assertEqual(descriptions, {"en": ("markdown", ENGLISH_NOTES), + "zh-Hans": ("markdown", CHINESE_NOTES)}) + enclosure = item.find("enclosure") + self.assertEqual(enclosure.get("url"), "https://github.com/sudoHG/AskKey/releases/download/" + f"v{VERSION}/AskKey-{VERSION}.dmg") + self.assertEqual(enclosure.get("length"), str(self.dmg.stat().st_size)) + self.assertTrue(verify(self.dmg, self.key, enclosure.get(SPARKLE + "edSignature"))) + self.assertEqual(list(self.scratch.iterdir()), []) + + def test_tampered_dmg_or_feed_fails_verification(self): + result = self.run_generator() + self.assertEqual(result.returncode, 0, result.stderr) + signature = self.item().find("enclosure").get(SPARKLE + "edSignature") + with self.dmg.open("ab") as stream: + stream.write(b"tampered") + self.assertFalse(verify(self.dmg, self.key, signature)) + feed = self.output.read_bytes() + self.output.write_bytes(feed.replace(f"{VERSION}<".encode(), + b"9.8.8<", 1)) + self.assertNotEqual(self.output.read_bytes(), feed) + self.assertFalse(verify(self.output, self.key)) + other = write_synthetic_key(self.directory / "other-key") + self.output.write_bytes(feed) + self.assertTrue(verify(self.output, self.key)) + self.assertFalse(verify(self.output, other)) + + def test_critical_writes_critical_update(self): + result = self.run_generator("--critical") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue(verify(self.output, self.key)) + critical = self.item().find(SPARKLE + "criticalUpdate") + self.assertIsNotNone(critical) + self.assertEqual(dict(critical.attrib), {}) + + def test_invalid_release_notes_are_rejected_before_signing(self): + cases = { + "missing Chinese": f"\n{ENGLISH_NOTES}\n", + "wrong order": f"\n{CHINESE_NOTES}\n\n{ENGLISH_NOTES}\n", + "text before English": f"Lead\n{RELEASE_NOTES}", + "empty English": f"\n\n\n{CHINESE_NOTES}\n", + "repeated marker": RELEASE_NOTES + f"\n{ENGLISH_NOTES}\n", + } + notes = self.directory / "invalid.md" + for case, text in cases.items(): + with self.subTest(case=case): + notes.write_text(text, encoding="utf-8") + check = subprocess.run(["python3", str(SCRIPT), "--check-release-notes", str(notes)], + text=True, capture_output=True, timeout=60) + self.assertEqual(check.returncode, 1) + self.assertIn("release notes", check.stderr.lower()) + result = self.run_generator(notes=notes) + self.assertEqual(result.returncode, 1) + self.assertFalse(self.output.exists()) + valid = subprocess.run(["python3", str(SCRIPT), "--check-release-notes", str(self.notes)], + text=True, capture_output=True, timeout=60) + self.assertEqual((valid.returncode, valid.stdout, valid.stderr), (0, "", "")) + + def test_invalid_key_is_never_printed_and_writes_nothing(self): + key = self.directory / "invalid-key" + key.write_text("synthetic-invalid-sparkle-key-text\n", encoding="ascii") + result = self.run_generator(key=key) + self.assertEqual(result.returncode, 1) + self.assertIn("Cannot sign the DMG", result.stderr) + self.assertNotIn("synthetic-invalid-sparkle-key-text", result.stdout + result.stderr) + self.assertNotIn(str(key), result.stdout + result.stderr) + self.assertFalse(self.output.exists()) + self.assertEqual(list(self.scratch.iterdir()), []) + + def test_existing_output_or_misnamed_dmg_is_rejected(self): + self.output.write_bytes(b"existing appcast") + result = self.run_generator() + self.assertEqual(result.returncode, 1) + self.assertIn("already exists", result.stderr) + self.assertEqual(self.output.read_bytes(), b"existing appcast") + self.output.unlink() + misnamed = self.directory / "AskKey.dmg" + misnamed.write_bytes(self.dmg.read_bytes()) + result = self.run_generator(dmg=misnamed) + self.assertEqual(result.returncode, 1) + self.assertIn(f"AskKey-{VERSION}.dmg", result.stderr) + self.assertFalse(self.output.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/Tests/Automation/test_package_release.py b/Tests/Automation/test_package_release.py index f0825dc..a105073 100644 --- a/Tests/Automation/test_package_release.py +++ b/Tests/Automation/test_package_release.py @@ -1,5 +1,6 @@ """Check real synthetic DMG packaging and fail-closed notarization gates.""" +import base64 import hashlib import json import os @@ -11,13 +12,17 @@ import tempfile import time import unittest +import xml.etree.ElementTree as ET ROOT = Path(__file__).resolve().parents[2] SCRIPT = ROOT / "scripts/package-release.sh" +SIGN_UPDATE = ROOT / ".build/artifacts/sparkle/Sparkle/bin/sign_update" SIGNING_ENV = ("ASKKEY_CODESIGN_IDENTITY", "ASKKEY_APPLE_TEAM_ID", "ASKKEY_NOTARY_PROFILE") SUBMISSION_ID = "12345678-1234-1234-1234-123456789abc" DMG_SUBMISSION_ID = "87654321-4321-4321-4321-cba987654321" NOTARY_CREDENTIAL = "synthetic-notary" +SPARKLE_CREDENTIAL = "synthetic-sparkle-key" +ENCLOSURE_SIGNATURE = "{http://www.andymatuschak.org/xml-namespaces/sparkle}edSignature" SYNTHETIC_MAPPINGS = { "PRIVATE_KEY_FILE": "synthetic-private-key-file", "KEY_ID": "synthetic-key-id", @@ -48,6 +53,13 @@ def setUp(self): self.environment = {key: value for key, value in os.environ.items() if not key.startswith("ASKKEY_") and key not in SYNTHETIC_MAPPINGS} self.environment["TMPDIR"] = str(self.scratch) + # A random Ed25519 seed file, as Sparkle exports one; no keychain item is created. + self.sparkle_key_text = base64.b64encode(os.urandom(32)).decode("ascii") + self.sparkle_key = self.directory / "sparkle-key" + self.sparkle_key.write_text(self.sparkle_key_text + "\n", encoding="ascii") + self.release_notes = self.directory / "release notes.md" + self.release_notes.write_text("\nSynthetic notes.\n\n" + "Synthetic Simplified Chinese notes.\n", encoding="utf-8") self.make_app() def make_app(self, version=None, identifier="com.sudohg.askkey.app", signed=True): @@ -73,12 +85,14 @@ def make_app(self, version=None, identifier="com.sudohg.askkey.app", signed=True subprocess.run(["codesign", "--force", "--sign", "-", str(self.app)], check=True, text=True, capture_output=True) - def run_script(self, *arguments, notarize=False, environment=None, output=True): + def run_script(self, *arguments, notarize=False, environment=None, output=True, appcast=True): command = ["bash", str(SCRIPT), "--app", str(self.app)] if output: command.extend(["--output", str(self.output)]) if not notarize: command.append("--no-notarize") + elif appcast: + command.extend(self.appcast_arguments()) return subprocess.run(command + list(map(str, arguments)), cwd=self.directory, env=environment or self.environment, text=True, capture_output=True, timeout=120) @@ -96,13 +110,28 @@ def assert_checksum(self, image): self.assertEqual(result.returncode, 0, result.stderr) self.assertIn(f"{image.name}: OK", result.stdout) + def appcast_arguments(self): + return ["--sparkle-key-credential", SPARKLE_CREDENTIAL, "--release-notes", str(self.release_notes)] + + def sign_update_verifies(self, path, signature=None): + command = [str(SIGN_UPDATE), "--verify", "--ed-key-file", str(self.sparkle_key), str(path)] + return subprocess.run(command + ([signature] if signature else []), + stdin=subprocess.DEVNULL, capture_output=True).returncode == 0 + def assert_notarized_artifacts(self, image): stable = self.output / "AskKey.dmg" + appcast = self.output / "appcast.xml" self.assertEqual({path.name for path in self.output.iterdir()}, - {image.name, image.name + ".sha256", stable.name, stable.name + ".sha256"}) + {image.name, image.name + ".sha256", stable.name, stable.name + ".sha256", + appcast.name}) self.assertEqual(stable.read_bytes(), image.read_bytes()) self.assert_checksum(image) self.assert_checksum(stable) + self.assertTrue(self.sign_update_verifies(appcast)) + enclosure = ET.parse(appcast).getroot().find("channel/item/enclosure") + self.assertTrue(enclosure.get("url").endswith(f"/v{self.version}/{image.name}")) + self.assertEqual(enclosure.get("length"), str(image.stat().st_size)) + self.assertTrue(self.sign_update_verifies(image, enclosure.get(ENCLOSURE_SIGNATURE))) def attach_image(self, image, mountpoint): if diskutil_image_attach_available(): @@ -220,6 +249,7 @@ def mock_tools(self): environment.update({ "PATH": f"{tools}{os.pathsep}{environment['PATH']}", "PACKAGE_TEST_TRACE": str(trace), + "PACKAGE_TEST_SPARKLE_KEY": str(self.sparkle_key), "ASKKEY_CODESIGN_IDENTITY": "synthetic-signing-identity", "ASKKEY_APPLE_TEAM_ID": "FAKETEAM01", "ASKKEY_NOTARY_PROFILE": "synthetic-notary-profile", @@ -235,6 +265,8 @@ def assert_settings_not_printed(self, result, environment): for value in SYNTHETIC_MAPPINGS.values(): self.assertNotIn(value, text) self.assertNotIn("synthetic-private-key-bytes", text) + for value in (self.sparkle_key_text, "synthetic-sparkle-key-", "SPARKLE_ED_KEY_FILE="): + self.assertNotIn(value, text) def assert_credential_request(self, call, action): self.assertEqual(call[:6], ["askkey", "run", "--wait-for-approval", "--credential", @@ -260,6 +292,7 @@ def credential_environment(self): environment.pop("ASKKEY_NOTARY_PROFILE") return environment, trace + @unittest.skipUnless(SIGN_UPDATE.is_file(), "needs the resolved Sparkle artifact") def test_mocked_notarization_checks_both_artifacts_before_checksum(self): environment, trace = self.mock_tools() result = self.run_script(notarize=True, environment=environment) @@ -291,6 +324,7 @@ def test_mocked_notarization_checks_both_artifacts_before_checksum(self): self.assertIn("context:primary-signature", assessments[1]) self.assertEqual(list(self.scratch.iterdir()), []) + @unittest.skipUnless(SIGN_UPDATE.is_file(), "needs the resolved Sparkle artifact") def test_credential_mode_builds_askkey_run_invocation(self): environment, trace = self.credential_environment() result = self.run_script("--notary-credential", NOTARY_CREDENTIAL, notarize=True, @@ -302,7 +336,8 @@ def test_credential_mode_builds_askkey_run_invocation(self): self.assertIn("Notarized: true", result.stdout) self.assert_notarized_artifacts(image) calls = [json.loads(line) for line in trace.read_text().splitlines()] - submissions = [call for call in calls if call[0] == "askkey"] + submissions = [call for call in calls if call[:5] == ["askkey", "run", "--wait-for-approval", + "--credential", NOTARY_CREDENTIAL]] self.assertEqual(len(submissions), 4) operation_ids = [self.assert_credential_request(call, action) for call, action in zip(submissions, ("submit", "info", "submit", "info"))] @@ -463,7 +498,7 @@ def test_interrupts_report_saved_ids_before_cleaning_staging(self): with self.subTest(signal=interrupted): process = subprocess.Popen( ["bash", str(SCRIPT), "--app", str(self.app), "--output", str(self.output), - "--notary-credential", NOTARY_CREDENTIAL], + "--notary-credential", NOTARY_CREDENTIAL, *self.appcast_arguments()], env=environment, cwd=self.directory, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, start_new_session=True) try: @@ -497,7 +532,7 @@ def test_existing_artifact_is_not_overwritten(self): def test_existing_stable_artifact_is_not_overwritten(self): environment, _ = self.mock_tools() self.output.mkdir() - for name in ("AskKey.dmg", "AskKey.dmg.sha256"): + for name in ("AskKey.dmg", "AskKey.dmg.sha256", "appcast.xml"): for kind in ("file", "symlink", "dangling-symlink"): with self.subTest(name=name, kind=kind): self.assert_existing_artifact_rejected(name, kind, environment, notarize=True) diff --git a/Tests/Automation/test_package_release_appcast.py b/Tests/Automation/test_package_release_appcast.py new file mode 100644 index 0000000..42df979 --- /dev/null +++ b/Tests/Automation/test_package_release_appcast.py @@ -0,0 +1,133 @@ +"""Check the signed appcast step of release packaging with synthetic tools and keys.""" + +import importlib.util +import json +from pathlib import Path +import plistlib +import subprocess +import unittest +import xml.etree.ElementTree as ET + +ROOT = Path(__file__).resolve().parents[2] +SPEC = importlib.util.spec_from_file_location("package_release_harness", + ROOT / "Tests/Automation/test_package_release.py") +package = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(package) +CRITICAL_UPDATE = "{http://www.andymatuschak.org/xml-namespaces/sparkle}criticalUpdate" + + +class PackageReleaseAppcastTests(unittest.TestCase): + def setUp(self): + # Reuse the packaging harness by composition so its tests do not run twice. + self.harness = package.PackageReleaseTests() + self.harness.setUp() + self.addCleanup(self.harness.doCleanups) + + def calls(self, trace): + return [json.loads(line) for line in trace.read_text().splitlines()] + + def test_notarizing_without_appcast_options_fails_before_work(self): + harness = self.harness + environment, trace = harness.mock_tools() + cases = ( + (["--release-notes", str(harness.release_notes)], + "--sparkle-key-credential is required when notarizing"), + (["--sparkle-key-credential", package.SPARKLE_CREDENTIAL], + "--release-notes is required when notarizing"), + (["--sparkle-key-credential", package.SPARKLE_CREDENTIAL, "--release-notes", + str(harness.directory / "missing.md")], "Release notes not found"), + ) + for arguments, message in cases: + with self.subTest(message=message): + result = harness.run_script(*arguments, notarize=True, environment=environment, + appcast=False) + self.assertNotEqual(result.returncode, 0) + self.assertIn(message, result.stderr) + self.assertFalse(trace.exists()) + harness.assert_no_artifacts() + harness.release_notes.write_text("\nEnglish only.\n", encoding="utf-8") + result = harness.run_script(notarize=True, environment=environment) + self.assertNotEqual(result.returncode, 0) + self.assertIn("Simplified Chinese section", result.stderr) + self.assertFalse(trace.exists()) + harness.assert_no_artifacts() + + def test_appcast_options_conflict_with_no_notarize(self): + harness = self.harness + for arguments in (["--sparkle-key-credential", package.SPARKLE_CREDENTIAL], + ["--release-notes", str(harness.release_notes)], ["--critical"]): + with self.subTest(arguments=arguments): + result = harness.run_script(*arguments) + self.assertNotEqual(result.returncode, 0) + self.assertIn("Appcast options and --no-notarize modes conflict", result.stderr) + harness.assert_no_artifacts() + + def test_bundle_version_mismatch_is_rejected_before_work(self): + harness = self.harness + environment, trace = harness.mock_tools() + plist = harness.app / "Contents/Info.plist" + info = plistlib.loads(plist.read_bytes()) + info["CFBundleVersion"] = "999" + plist.write_bytes(plistlib.dumps(info)) + result = harness.run_script(notarize=True, environment=environment) + self.assertNotEqual(result.returncode, 0) + self.assertIn("App bundle version does not match the product version", result.stderr) + self.assertFalse(trace.exists()) + harness.assert_no_artifacts() + + @unittest.skipUnless(package.SIGN_UPDATE.is_file(), "needs the resolved Sparkle artifact") + def test_appcast_runs_through_askkey_and_critical_writes_critical_update(self): + harness = self.harness + environment, trace = harness.mock_tools() + result = harness.run_script("--critical", notarize=True, environment=environment) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + harness.assert_settings_not_printed(result, environment) + image = harness.output / f"AskKey-{harness.version}.dmg" + harness.assert_notarized_artifacts(image) + appcast = harness.output / "appcast.xml" + self.assertIn(f"Appcast: {appcast.resolve()}", result.stdout) + self.assertEqual(result.stdout.count("Approve the Ask Key card to sign the update feed."), 1) + self.assertIsNotNone(ET.parse(appcast).getroot().find("channel/item/" + CRITICAL_UPDATE)) + requests = [call for call in self.calls(trace) if call[0] == "askkey"] + self.assertEqual(len(requests), 1) + call = requests[0] + self.assertEqual(call[:6], ["askkey", "run", "--wait-for-approval", "--credential", + package.SPARKLE_CREDENTIAL, "--operation-id"]) + self.assertTrue(call[6]) + self.assertEqual(call[7:14], [ + "--caller-name", "AskKey release", "--caller-purpose", + f"Sign the AskKey {harness.version} update feed", "--", "/bin/bash", "-c", + ]) + self.assertIn('--ed-key-file "$SPARKLE_ED_KEY_FILE"', call[14]) + self.assertLess(len(call[14].encode()), 512) # Well below the broker's 4 KiB limit. + self.assertEqual(call[15:17], ["bash", str(ROOT / "scripts/generate-appcast.py")]) + self.assertEqual(Path(call[17]).name, image.name) + self.assertEqual(call[18:23], ["--version", harness.version, "--release-notes", + str(harness.release_notes.resolve()), "--output"]) + self.assertEqual(Path(call[23]).name, "appcast.xml") + self.assertEqual(call[24:], ["--critical"]) + self.assertFalse(any(str(harness.sparkle_key) in argument or harness.sparkle_key_text in argument + for argument in call)) + self.assertEqual(list(harness.scratch.iterdir()), []) + + @unittest.skipUnless(package.SIGN_UPDATE.is_file(), "needs the resolved Sparkle artifact") + def test_appcast_failures_write_nothing(self): + harness = self.harness + environment, trace = harness.mock_tools() + for failure in ("sparkle-approval-denied", "invalid-key"): + with self.subTest(failure=failure): + environment["PACKAGE_TEST_FAILURE"] = failure + if failure == "invalid-key": + harness.sparkle_key.write_text("synthetic-invalid-sparkle-key\n", encoding="ascii") + result = harness.run_script(notarize=True, environment=environment) + self.assertNotEqual(result.returncode, 0) + self.assertIn("Cannot write the signed appcast", result.stderr) + harness.assert_settings_not_printed(result, environment) + self.assertNotIn("synthetic-invalid-sparkle-key", result.stdout + result.stderr) + self.assertEqual(sum(call[0] == "askkey" for call in self.calls(trace)), 1) + harness.assert_no_artifacts() + trace.unlink() + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/adr/0006-release-process.md b/docs/adr/0006-release-process.md index 1b108c0..5f8d5e8 100644 --- a/docs/adr/0006-release-process.md +++ b/docs/adr/0006-release-process.md @@ -13,8 +13,12 @@ The repository is public, but users can only build AskKey from source. The offic - **Where it runs.** Release builds are signed and notarized on the maintainer's Mac. The Developer ID certificate and notarization credentials stay in the maintainer's login keychain; notarization uses a `notarytool` keychain profile named by an environment variable. No signing certificate, notarization key or password is stored in GitHub secrets, CI or the repository. - **Version.** One Swift constant is the product version. The helper's MCP `serverInfo`, `askkey status`, client verification and the bundle's `CFBundleShortVersionString` all read from it. A release is tagged `v` on a commit reachable from `main`. - **Gate.** A release or `Local` build requires a clean checkout whose `HEAD` has a successful `CI` workflow run from a push to `main`, with both `build-and-test` and `basic-ui-flows` passing. A `Release` build additionally requires the tag `v` to point at `HEAD`. CI's desktop flows replace the local E2E receipt for this purpose. -- **Artifacts.** A DMG containing `Ask Key.app` and an `Applications` link. The app is notarized and stapled first; the DMG is then signed, notarized and stapled. Gatekeeper must accept both before writing the four output files: `AskKey-.dmg`, `AskKey-.dmg.sha256`, `AskKey.dmg`, and `AskKey.dmg.sha256`. The stable-named DMG is a byte-identical copy of the versioned DMG; each SHA-256 checksum file names its corresponding DMG. Packaging rejects any existing output file or symlink before writing any of the four files. Synthetic `--no-notarize` builds write only `AskKey--unnotarized.dmg` and its checksum, with the same conflict checks. -- **Publication.** Upload all four notarized files and release notes to a draft GitHub Release. The stable names support direct downloads through `releases/latest/download/AskKey.dmg` and `releases/latest/download/AskKey.dmg.sha256`. The maintainer approves before the draft is published and before the tag is pushed. ~~There is no updater: users check GitHub Releases for new versions.~~ Superseded by [ADR 0011](0011-in-app-updates.md): the official Release install checks a signed appcast that is published as a Release asset. +- **Artifacts.** A DMG containing `Ask Key.app` and an `Applications` link. The app is notarized and stapled first; the DMG is then signed, notarized and stapled. Gatekeeper must accept both before writing the five output files: `AskKey-.dmg`, `AskKey-.dmg.sha256`, `AskKey.dmg`, `AskKey.dmg.sha256`, and `appcast.xml`. The stable-named DMG is a byte-identical copy of the versioned DMG; each SHA-256 checksum file names its corresponding DMG. `appcast.xml` is the signed update feed for [ADR 0011](0011-in-app-updates.md): `--sparkle-key-credential` names the Ask Key credential that delivers the Sparkle private key, and the feed embeds the `--release-notes` file. Packaging rejects any existing output file or symlink before writing any of the five files. Synthetic `--no-notarize` builds write only `AskKey--unnotarized.dmg` and its checksum, with the same conflict checks, and no appcast. +- **Publication.** Upload all five files (the four DMG files and `appcast.xml`) and release notes to a draft GitHub Release. The stable names support direct downloads through `releases/latest/download/AskKey.dmg` and `releases/latest/download/AskKey.dmg.sha256`, and `https://askkey.sudohg.com/appcast.xml` redirects to `releases/latest/download/appcast.xml`. The maintainer approves before the draft is published and before the tag is pushed. Publishing the release makes the feed live; no website deploy is needed. ~~There is no updater: users check GitHub Releases for new versions.~~ Superseded by [ADR 0011](0011-in-app-updates.md): the official Release install checks a signed appcast that is published as a Release asset. + - `releases/latest` is the newest published release that is not a pre-release. A release published without `appcast.xml` makes the feed return 404, and every installed copy silently stops seeing updates until the asset is uploaded. After publishing, read the feed back as described in [docs/testing.md](../testing.md): download it, verify its signature, check its version, and check that its enclosure downloads with the stated length. + - Security fixes are released with `--critical`. Release notes for a release that migrates the library state its downgrade limit. + - To pull a bad update, replace the latest release's `appcast.xml` asset with the previous release's (`gh release upload --clobber`). Sparkle never downgrades, so this only stops the offer; then ship a higher, fixed version. + - Serving the feed directly from the website, or from a mirror for mainland China, is a later change with its own issue. From then on, each release also needs a website deploy. - **Not included.** ~~Sparkle or any in-app updater,~~ Homebrew, the Mac App Store, and CI-based signing each need their own issue. The updater is decided in [ADR 0011](0011-in-app-updates.md), which supersedes this exclusion. Lokalite's signing team, keys and release entries are never reused. ## Consequences diff --git a/docs/adr/0011-in-app-updates.md b/docs/adr/0011-in-app-updates.md index 5498667..9357ead 100644 --- a/docs/adr/0011-in-app-updates.md +++ b/docs/adr/0011-in-app-updates.md @@ -26,6 +26,10 @@ Users install Ask Key by replacing `/Applications/Ask Key.app` with the app from **Packaging.** `scripts/build-app.sh` embeds `Sparkle.framework` in `Contents/Frameworks` in every mode, so the binary always launches, and removes its `XPCServices`, which a non-sandboxed app does not need. It makes sure the executable's rpath includes `@executable_path/../Frameworks` and removes rpaths into the checkout or derived data, so dyld loads only the embedded framework and no local path ships. It signs nested code inside out with the existing options (Sparkle's `Autoupdate`, `Updater.app`, the framework, the helper and its resource bundle, then the app) without `--deep`, which is kept for verification. Only `Release` mode writes the Sparkle keys from decisions 2 and 3; Debug, `Local` and E2E bundles carry none. The app stays unsandboxed. +**Publication.** Every notarized release writes `appcast.xml` with [generate-appcast.py](../../scripts/generate-appcast.py) and publishes it as a Release asset ([ADR 0006](0006-release-process.md)); until a release carries it, the redirect target returns 404. The feed has one item. `sparkle:version` and `sparkle:shortVersionString` are the product version, which packaging requires to equal the app's `CFBundleVersion`; `sparkle:minimumSystemVersion` is `14.0.0`; the enclosure is the versioned DMG URL from decision 2 with its `length` and `sparkle:edSignature`; `--critical` adds an empty `sparkle:criticalUpdate`, which marks the update critical for every older version. The release notes are embedded in the signed feed as Markdown `description` elements in English and Simplified Chinese (`xml:lang` `en` and `zh-Hans`). Sparkle 2.10.1 picks the one that best matches the macOS preferred languages (`SUAppcast.m:237-276`) and renders Markdown in a text view, not a web view (`SUUpdateAlert.m:181-195`, `SUUpdateAlert.m:333-339`). There is no `sparkle:releaseNotesLink`, so showing the notes fetches nothing; `sparkle:fullReleaseNotesLink` names the GitHub Release and is only opened in the browser when the user clicks Version History (`SPUStandardUserDriver.m:679-685`). `sign_update` from the resolved SwiftPM artifact signs the DMG and then the feed with the key file that Ask Key delivers in `SPARKLE_ED_KEY_FILE` to one `askkey run --wait-for-approval` request, and verifies both before the feed is written. Its output, which can echo key material, is never shown, and no keychain item is created. + +Sparkle follows the website's redirect to GitHub and GitHub's redirects to its asset host. The downloader runs in process because the Info.plist does not set `SUEnableDownloaderService` (`SPUDownloadDriver.m:58-60`, `SPUDownloadDriver.m:108-112`, `SPUXPCServiceInfo.m:16-22`) and uses a default `NSURLSession` whose delegate does not implement `URLSession:task:willPerformHTTPRedirection:newRequest:completionHandler:` (`SPUDownloader.m:27`, `SPUDownloader.m:83-86`; the method appears nowhere in Sparkle's `Downloader` or `Sparkle` sources), so `NSURLSession` follows redirects itself. Only the initial URL's scheme is checked (`SPUDownloader.m:65-81`), the status check sees the final response (`SPUDownloader.m:191-211`), and relative appcast URLs resolve against the final URL (`SPUDownloader.m:214-224`, `SUAppcastDriver.m:185`); the feed uses absolute URLs. No host is trusted for integrity: the feed and DMG signatures are. + ## Consequences Users of the official install learn about updates and install them with one confirmation, and releases still need only the maintainer's approval to publish. The appcast, its signing and the website redirect are release work in #259; until #258 lands, `Release` builds fail by design. Update checks add a network request that reveals the IP address, app version and preferred languages to Cloudflare and GitHub, as documented in the README and [SECURITY.md](../../SECURITY.md). A relaunch waits for as long as agent work continues. diff --git a/docs/testing.md b/docs/testing.md index 6795d91..9e3a8df 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -116,9 +116,9 @@ Desktop tests exercise shared App/Broker/helper behavior, but system authenticat ## Release packaging -[package-release.sh](../scripts/package-release.sh) writes notarized releases as four files: `AskKey-.dmg`, `AskKey-.dmg.sha256`, `AskKey.dmg`, and `AskKey.dmg.sha256`. The stable-named DMG is a byte-identical copy made after all notarization, stapling and Gatekeeper checks pass. Each checksum file names its corresponding DMG. Any existing file or symlink at an output name aborts packaging before any output file is written. The default output directory is `.build/release-artifacts`, not SwiftPM's `.build/release` symlink, which resolves into the build products tree. Pass `--output` to choose another directory. +[package-release.sh](../scripts/package-release.sh) writes notarized releases as five files: `AskKey-.dmg`, `AskKey-.dmg.sha256`, `AskKey.dmg`, `AskKey.dmg.sha256`, and the signed update feed `appcast.xml`. The stable-named DMG is a byte-identical copy made after all notarization, stapling and Gatekeeper checks pass. Each checksum file names its corresponding DMG. Any existing file or symlink at an output name aborts packaging before any output file is written. The default output directory is `.build/release-artifacts`, not SwiftPM's `.build/release` symlink, which resolves into the build products tree. Pass `--output` to choose another directory. -Notarization needs exactly one of `ASKKEY_NOTARY_PROFILE` (a `notarytool` keychain profile) or `--notary-credential ` (an Ask Key credential). `--no-notarize` skips notarization for synthetic packaging checks, writes only `AskKey--unnotarized.dmg` and its checksum, and cannot be combined with `--notary-credential`. The two notarization modes cannot be combined. +Notarization needs exactly one of `ASKKEY_NOTARY_PROFILE` (a `notarytool` keychain profile) or `--notary-credential ` (an Ask Key credential). `--no-notarize` skips notarization for synthetic packaging checks, writes only `AskKey--unnotarized.dmg` and its checksum and no appcast, and cannot be combined with `--notary-credential`, `--sparkle-key-credential`, `--release-notes` or `--critical`. The two notarization modes cannot be combined. In credential mode the runner invokes the helper separately for each submit, history query, or status query. Each target shell runs one notarytool request: @@ -134,6 +134,16 @@ A transient submit failure without an ID first reconciles `notarytool history` b An adopted submission still In Progress at the deadline gets a distinct diagnostic: its upload may be incomplete, so inspect its notarytool log before resubmitting. Failures retain all observed IDs with credential-free log commands; INT and TERM handlers also print IDs already saved in the response file before cleanup. An upload-started event preserves history guidance even if a later approval or credential-delivery request fails without an ID. Before any upload attempt, approval/delivery failures do not suggest history lookup; a helper killed at the overall deadline reports the deadline rather than approval denial. Resuming stapling from an existing submission ID is outside this flow. +Notarized runs also need `--sparkle-key-credential `, the Ask Key credential that delivers the Sparkle private key file as `SPARKLE_ED_KEY_FILE`, and `--release-notes `; `--critical` marks the update critical and is used for security fixes. The options, the release-notes format, and an app `CFBundleVersion` equal to the product version are checked before any notarization request. After the DMG passes Gatekeeper and the output names are free, packaging asks for one approval: + +```text +askkey run --wait-for-approval --credential --operation-id --caller-name "AskKey release" --caller-purpose "Sign the AskKey update feed" -- /bin/bash -c 'set +x; set -eu; exec python3 "$@" --ed-key-file "$SPARKLE_ED_KEY_FILE"' bash scripts/generate-appcast.py --version --release-notes --output /appcast.xml [--critical] +``` + +[generate-appcast.py](../scripts/generate-appcast.py) signs the stapled DMG and then the feed with `sign_update` from `.build/artifacts/sparkle/Sparkle/bin`, the SwiftPM artifact pinned by `Package.resolved` (present after `swift build` or `swift package resolve`), and verifies both before writing the feed. The key path expands only in the target shell, `sign_update` output is never shown because it can echo key material, and no keychain item is read or created. [ADR 0011](adr/0011-in-app-updates.md) (Publication) describes the feed's item. Automation tests use only synthetic Ed25519 seed files in temporary directories and the fixture `askkey`. + +The release-notes file is UTF-8 Markdown with two sections, each starting with its marker line: `` for English, then `` for Simplified Chinese, with nothing but blank lines before the first marker. Both are embedded in the signed feed, and the update window shows the one that best matches the Mac's preferred languages, so describe what changed for the user and leave download and install steps to the GitHub Release. Release notes for a release that migrates the library state its downgrade limit in both sections. + ## Pre-release checks on the maintainer's Mac CI cannot check focus, Touch ID or real clients. Before notarizing, review the release diff for these behaviors, then install the signed `Release` build at `/Applications/Ask Key.app` and walk through this list once with the maintainer. The planner triggers agent requests itself (for example `askkey run --wait-for-approval` with a harmless command that prints no values, or a Codex session), one request at a time with explicit instructions; delay requests that coincide with screen lock so the maintainer never waits at the lock screen. The maintainer only touches the sensor, clicks decisions and reports what they see. Notarize the same build afterwards; its signature hash must match the one tested. Device checks remain `NOT_VERIFIED` until performed; passing synthetic tests or CI is not device verification. @@ -154,7 +164,24 @@ CI cannot check focus, Touch ID or real clients. Before notarizing, review the r - If an external or scaled display is available, expand a long group approval on a short screen in English and Simplified Chinese and check that the title and all action buttons stay visible while Details scrolls. Check centred empty states for an empty library, an empty group and a search with no results, with and without an active allowance row above them. Record when the physical-display check is unavailable instead of treating simulated layout tests as device verification. - Clicking the menu-bar item with the management window open behind another app. - The sample prompt from Agent access runs as written in a new client session; each supported client that is installed still reports Connected; Claude Code setup still detects its discovery hooks; an SSH request is reminded to look up credentials first. -- After notarization, verify both checksum files in the output directory with `shasum -a 256 -c AskKey-.dmg.sha256` and `shasum -a 256 -c AskKey.dmg.sha256` (replace `` with the release version). Upload all four files (`AskKey-.dmg`, `AskKey-.dmg.sha256`, `AskKey.dmg`, and `AskKey.dmg.sha256`) to the draft release. Obtain the maintainer's explicit approval before pushing the tag or publishing the release, as required by [ADR 0006](adr/0006-release-process.md). +- After notarization, verify both checksum files in the output directory with `shasum -a 256 -c AskKey-.dmg.sha256` and `shasum -a 256 -c AskKey.dmg.sha256` (replace `` with the release version). Upload all five files (`AskKey-.dmg`, `AskKey-.dmg.sha256`, `AskKey.dmg`, `AskKey.dmg.sha256`, and `appcast.xml`) to the draft release. Obtain the maintainer's explicit approval before pushing the tag or publishing the release, as required by [ADR 0006](adr/0006-release-process.md). Publishing the release makes the feed live through the website's redirect to `releases/latest/download/appcast.xml`; no website deploy is needed. +- Right after publishing, read the feed back. `releases/latest` is the newest published release that is not a pre-release, so a release published without `appcast.xml` makes the feed return 404, and every installed copy silently stops seeing updates until the asset is uploaded. `sign_update --verify` derives the public key from the private key file, so the signature check runs through the Sparkle key credential: + + ```bash + VERSION="$(bash scripts/product-version.sh)" + FEED_DIR="$(mktemp -d)" + curl -fsSL -o "$FEED_DIR/appcast.xml" https://askkey.sudohg.com/appcast.xml + "/Applications/Ask Key.app/Contents/Helpers/askkey" run --wait-for-approval --credential --operation-id "$(uuidgen)" --caller-name "AskKey release" --caller-purpose "Verify the AskKey $VERSION update feed" -- /bin/bash -c 'set +x; set -eu; "$1" --verify --ed-key-file "$SPARKLE_ED_KEY_FILE" "$2" >/dev/null 2>&1' bash "$PWD/.build/artifacts/sparkle/Sparkle/bin/sign_update" "$FEED_DIR/appcast.xml" && echo "Feed signature verified" + grep -Fq "$VERSION" "$FEED_DIR/appcast.xml" && echo "Feed version $VERSION" + URL="$(sed -n 's/.* --repo sudoHG/AskKey --pattern appcast.xml --dir `, upload it with `gh release upload v /appcast.xml --repo sudoHG/AskKey --clobber`, and read the feed back. Sparkle never downgrades, so this only stops the offer to copies that have not installed the update; then ship a higher, fixed version. Serving the feed directly from the website, or from a mirror for mainland China, is a later change with its own issue; from then on, each release also needs a website deploy. ## Receipt and review diff --git a/scripts/generate-appcast.py b/scripts/generate-appcast.py new file mode 100644 index 0000000..d96d341 --- /dev/null +++ b/scripts/generate-appcast.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +"""Write a signed Sparkle appcast with one item for a notarized release DMG. + +The DMG and the feed are signed with Sparkle's sign_update from the resolved +SwiftPM artifact and verified before the feed is written. Release notes are +embedded in the signed feed in English and Simplified Chinese. +""" + +import argparse +import base64 +import binascii +from datetime import datetime, timezone +from email.utils import format_datetime +import os +from pathlib import Path +import re +import subprocess +import sys +import tempfile +import xml.etree.ElementTree as ET + +ROOT = Path(__file__).resolve().parents[1] +# SwiftPM resolves this artifact at the version pinned by Package.resolved. +SIGN_UPDATE = ROOT / ".build/artifacts/sparkle/Sparkle/bin/sign_update" +SPARKLE = "http://www.andymatuschak.org/xml-namespaces/sparkle" +XML_LANG = "{http://www.w3.org/XML/1998/namespace}lang" +RELEASES = "https://github.com/sudoHG/AskKey/releases" +MINIMUM_SYSTEM_VERSION = "14.0.0" +# Each language section starts with its marker line, English first. +NOTES_MARKERS = (("", "en"), ("", "zh-Hans")) +VERSION = re.compile(r"[0-9]+\.[0-9]+\.[0-9]+") + + +class Failure(Exception): + pass + + +def read_release_notes(path): + try: + lines = Path(path).read_text(encoding="utf-8").splitlines() + except (OSError, UnicodeDecodeError): + raise Failure("Release notes must be a readable UTF-8 file") from None + markers = dict(NOTES_MARKERS) + starts = [index for index, line in enumerate(lines) if line.strip() in markers] + if ([markers[lines[index].strip()] for index in starts] != [language for _, language in NOTES_MARKERS] + or any(line.strip() for line in lines[:starts[0]])): + raise Failure("Release notes must contain an English section and then a Simplified Chinese " + "section, each starting with its marker line: " + + " and ".join(marker for marker, _ in NOTES_MARKERS)) + sections = [] + for (_, language), start, end in zip(NOTES_MARKERS, starts, starts[1:] + [len(lines)]): + text = "\n".join(lines[start + 1:end]).strip() + if not text: + raise Failure(f"The {language} release notes are empty") + sections.append((language, text)) + return sections + + +def sign_update(*arguments): + """Run sign_update; its output can echo key material, so it is never shown.""" + try: + return subprocess.run([str(SIGN_UPDATE), *arguments], stdin=subprocess.DEVNULL, + capture_output=True) + except OSError: + raise Failure("Cannot run Sparkle sign_update") from None + + +def sign_archive(archive, key_file): + result = sign_update("-p", "--ed-key-file", str(key_file), str(archive)) + signature = result.stdout.decode("ascii", errors="replace").strip() + try: + valid = result.returncode == 0 and len(base64.b64decode(signature, validate=True)) == 64 + except (binascii.Error, ValueError): + valid = False + if not valid: + raise Failure("Cannot sign the DMG with the Sparkle key") + if sign_update("--verify", "--ed-key-file", str(key_file), str(archive), signature).returncode != 0: + raise Failure("The DMG signature did not verify") + return signature + + +def sparkle_element(parent, name, text=None): + element = ET.SubElement(parent, f"{{{SPARKLE}}}{name}") + element.text = text + return element + + +def feed(version, length, signature, notes, critical, published): + ET.register_namespace("sparkle", SPARKLE) + rss = ET.Element("rss", {"version": "2.0"}) + channel = ET.SubElement(rss, "channel") + for tag, text in (("title", "Ask Key"), ("link", RELEASES), ("description", "Ask Key updates")): + ET.SubElement(channel, tag).text = text + item = ET.SubElement(channel, "item") + ET.SubElement(item, "title").text = f"Ask Key {version}" + ET.SubElement(item, "pubDate").text = format_datetime(published) + sparkle_element(item, "version", version) + sparkle_element(item, "shortVersionString", version) + sparkle_element(item, "minimumSystemVersion", MINIMUM_SYSTEM_VERSION) + if critical: + sparkle_element(item, "criticalUpdate") + sparkle_element(item, "fullReleaseNotesLink", f"{RELEASES}/tag/v{version}") + for language, text in notes: + ET.SubElement(item, "description", + {f"{{{SPARKLE}}}format": "markdown", XML_LANG: language}).text = text + ET.SubElement(item, "enclosure", { + "url": f"{RELEASES}/download/v{version}/AskKey-{version}.dmg", + "length": str(length), + "type": "application/octet-stream", + f"{{{SPARKLE}}}edSignature": signature, + }) + ET.indent(rss) + return ET.tostring(rss, encoding="utf-8", xml_declaration=True) + b"\n" + + +def write_appcast(dmg, version, notes_path, key_file, output, critical): + if not VERSION.fullmatch(version): + raise Failure("Version must match X.Y.Z with numeric components") + if dmg.name != f"AskKey-{version}.dmg" or not dmg.is_file(): + raise Failure(f"The DMG must be an existing AskKey-{version}.dmg") + if os.path.lexists(output): + raise Failure("The appcast output already exists") + notes = read_release_notes(notes_path) + if not (SIGN_UPDATE.is_file() and os.access(SIGN_UPDATE, os.X_OK)): + raise Failure("Sparkle sign_update was not found; run `swift package resolve` first") + if not (key_file.is_file() and os.access(key_file, os.R_OK)): + raise Failure("The Sparkle private key file is missing or unreadable") + signature = sign_archive(dmg, key_file) + data = feed(version, dmg.stat().st_size, signature, notes, critical, + datetime.now(timezone.utc).replace(microsecond=0)) + with tempfile.TemporaryDirectory(prefix="askkey-appcast-") as directory: + # sign_update treats a path ending in .xml as a feed and embeds the signature. + unsigned = Path(directory) / "appcast.xml" + unsigned.write_bytes(data) + if sign_update("--disable-signing-warning", "--ed-key-file", str(key_file), + str(unsigned)).returncode != 0: + raise Failure("Cannot sign the appcast with the Sparkle key") + if sign_update("--verify", "--ed-key-file", str(key_file), str(unsigned)).returncode != 0: + raise Failure("The appcast signature did not verify") + signed = unsigned.read_bytes() + with open(output, "xb") as stream: + stream.write(signed) + + +def main(argv=None): + arguments = sys.argv[1:] if argv is None else argv + try: + if arguments[:1] == ["--check-release-notes"] and len(arguments) == 2: + read_release_notes(arguments[1]) + return 0 + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("dmg", type=Path) + parser.add_argument("--version", required=True) + parser.add_argument("--release-notes", type=Path, required=True) + parser.add_argument("--ed-key-file", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--critical", action="store_true") + args = parser.parse_args(arguments) + write_appcast(args.dmg, args.version, args.release_notes, args.ed_key_file, + args.output, args.critical) + return 0 + except Failure as error: + print(f"Error: {error}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/package-release.sh b/scripts/package-release.sh index d7aa662..82305a4 100755 --- a/scripts/package-release.sh +++ b/scripts/package-release.sh @@ -8,7 +8,12 @@ APP="$ROOT_DIR/.build/AskKeyApp.app" OUTPUT="$ROOT_DIR/.build/release-artifacts" NOTARIZE=true NOTARY_CREDENTIAL="" +SPARKLE_KEY_CREDENTIAL="" +RELEASE_NOTES="" +CRITICAL=false ASKKEY_RELEASE_HELPER="${ASKKEY_RELEASE_HELPER:-/Applications/Ask Key.app/Contents/Helpers/askkey}" +# The delivered key file path expands only in the helper's target shell. +APPCAST_TARGET='set +x; set -eu; exec python3 "$@" --ed-key-file "$SPARKLE_ED_KEY_FILE"' fail() { printf 'Error: %s\n' "$1" >&2 @@ -16,21 +21,28 @@ fail() { } usage() { - echo "Usage: $0 [--app PATH] [--output DIR] [--notary-credential NAME] [--no-notarize]" + echo "Usage: $0 [--app PATH] [--output DIR] [--notary-credential NAME]" + echo " [--sparkle-key-credential NAME --release-notes PATH [--critical]] [--no-notarize]" + echo "Notarized runs need --sparkle-key-credential and --release-notes and also write appcast.xml." } while [[ "$#" -gt 0 ]]; do case "$1" in - --app|--output) + --app|--output|--release-notes) [[ "$#" -ge 2 && -n "$2" && "$2" != --* ]] || fail "$1 requires a path" - if [[ "$1" == --app ]]; then APP="$2"; else OUTPUT="$2"; fi + case "$1" in + --app) APP="$2" ;; + --output) OUTPUT="$2" ;; + *) RELEASE_NOTES="$2" ;; + esac shift 2 ;; - --notary-credential) + --notary-credential|--sparkle-key-credential) [[ "$#" -ge 2 && -n "$2" && "$2" != --* ]] || fail "$1 requires a name" - NOTARY_CREDENTIAL="$2" + if [[ "$1" == --notary-credential ]]; then NOTARY_CREDENTIAL="$2"; else SPARKLE_KEY_CREDENTIAL="$2"; fi shift 2 ;; + --critical) CRITICAL=true; shift ;; --no-notarize) NOTARIZE=false; shift ;; --help|-h) usage; exit 0 ;; *) usage >&2; fail "Unknown argument" ;; @@ -40,6 +52,9 @@ done if [[ -n "$NOTARY_CREDENTIAL" && "$NOTARIZE" == false ]]; then fail "Notary credential and --no-notarize modes conflict" fi +if [[ "$NOTARIZE" == false && ( -n "$SPARKLE_KEY_CREDENTIAL" || -n "$RELEASE_NOTES" || "$CRITICAL" == true ) ]]; then + fail "Appcast options and --no-notarize modes conflict" +fi if [[ -n "$NOTARY_CREDENTIAL" && -n "${ASKKEY_NOTARY_PROFILE:-}" ]]; then fail "Notary credential and ASKKEY_NOTARY_PROFILE modes conflict" fi @@ -50,6 +65,11 @@ if [[ "$NOTARIZE" == true ]]; then fail "Exactly one of --notary-credential or ASKKEY_NOTARY_PROFILE is required" fi [[ "$ASKKEY_CODESIGN_IDENTITY" != - ]] || fail "A Developer ID signing identity is required" + [[ -n "$SPARKLE_KEY_CREDENTIAL" ]] || fail "--sparkle-key-credential is required when notarizing" + [[ -n "$RELEASE_NOTES" ]] || fail "--release-notes is required when notarizing" + [[ -f "$RELEASE_NOTES" ]] || fail "Release notes not found: $RELEASE_NOTES" + RELEASE_NOTES="$(CDPATH= cd -- "$(dirname -- "$RELEASE_NOTES")" && pwd -P)/$(basename -- "$RELEASE_NOTES")" + python3 "$ROOT_DIR/scripts/generate-appcast.py" --check-release-notes "$RELEASE_NOTES" || exit 1 fi [[ -d "$APP" ]] || fail "App bundle not found: $APP" @@ -60,6 +80,12 @@ PLIST="$APP/Contents/Info.plist" APP_VERSION="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$PLIST" 2>/dev/null)" \ || fail "App version is missing or invalid" [[ "$APP_VERSION" == "$VERSION" ]] || fail "App version does not match the product version" +if [[ "$NOTARIZE" == true ]]; then + # The appcast's sparkle:version is the product version, so it must be CFBundleVersion. + BUNDLE_VERSION="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$PLIST" 2>/dev/null)" \ + || fail "App bundle version is missing or invalid" + [[ "$BUNDLE_VERSION" == "$VERSION" ]] || fail "App bundle version does not match the product version" +fi BUNDLE_ID="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$PLIST" 2>/dev/null)" \ || fail "App bundle identifier is missing or invalid" [[ "$BUNDLE_ID" == com.sudohg.askkey.app ]] || fail "App bundle identifier must be com.sudohg.askkey.app" @@ -136,11 +162,27 @@ fi # Do not expose a release-named image until every requested check succeeds. OUTPUT_NAMES=("$DMG_NAME" "$DMG_NAME.sha256") -if [[ "$NOTARIZE" == true ]]; then OUTPUT_NAMES+=("AskKey.dmg" "AskKey.dmg.sha256"); fi +if [[ "$NOTARIZE" == true ]]; then OUTPUT_NAMES+=("AskKey.dmg" "AskKey.dmg.sha256" "appcast.xml"); fi for name in "${OUTPUT_NAMES[@]}"; do [[ ! -e "$OUTPUT/$name" && ! -L "$OUTPUT/$name" ]] \ || fail "Output artifacts already exist; choose an empty output directory" done + +if [[ "$NOTARIZE" == true ]]; then + # Sign the final, stapled DMG. The generator signs and verifies the DMG and the + # feed, and never prints sign_update output, which can echo key material. + APPCAST_ARGUMENTS=("$ROOT_DIR/scripts/generate-appcast.py" "$DMG" --version "$VERSION" + --release-notes "$RELEASE_NOTES" --output "$WORK_DIR/appcast.xml") + if [[ "$CRITICAL" == true ]]; then APPCAST_ARGUMENTS+=(--critical); fi + printf 'Approve the Ask Key card to sign the update feed.\n' + "$ASKKEY_RELEASE_HELPER" run --wait-for-approval --credential "$SPARKLE_KEY_CREDENTIAL" \ + --operation-id "$(uuidgen)" --caller-name "AskKey release" \ + --caller-purpose "Sign the AskKey $VERSION update feed" -- \ + /bin/bash -c "$APPCAST_TARGET" bash "${APPCAST_ARGUMENTS[@]}" >/dev/null \ + || fail "Cannot write the signed appcast; check Ask Key approval, credential delivery and sign_update" + [[ -f "$WORK_DIR/appcast.xml" ]] || fail "The signed appcast was not written" +fi + mkdir -p "$OUTPUT" OUTPUT="$(CDPATH= cd -- "$OUTPUT" && pwd -P)" ditto "$DMG" "$OUTPUT/$DMG_NAME" || fail "Cannot write the DMG to the output directory" @@ -148,6 +190,8 @@ ditto "$DMG" "$OUTPUT/$DMG_NAME" || fail "Cannot write the DMG to the output dir if [[ "$NOTARIZE" == true ]]; then ditto "$OUTPUT/$DMG_NAME" "$OUTPUT/AskKey.dmg" || fail "Cannot write the stable-named DMG" (cd "$OUTPUT" && shasum -a 256 AskKey.dmg >AskKey.dmg.sha256) + ditto "$WORK_DIR/appcast.xml" "$OUTPUT/appcast.xml" || fail "Cannot write the appcast" fi SHA256="$(awk '{print $1}' "$OUTPUT/$DMG_NAME.sha256")" printf 'DMG: %s\nSHA-256: %s\nNotarized: %s\n' "$OUTPUT/$DMG_NAME" "$SHA256" "$NOTARIZE" +if [[ "$NOTARIZE" == true ]]; then printf 'Appcast: %s\n' "$OUTPUT/appcast.xml"; fi diff --git a/site-src/README.md b/site-src/README.md index f431b96..8bb824b 100644 --- a/site-src/README.md +++ b/site-src/README.md @@ -15,6 +15,8 @@ Wrangler reads a Cloudflare API token with Pages write access from `CLOUDFLARE_A Before deploying, run `npm run stamp` in `how/` if any CSS or JS changed, so pages never pair with a cached old file, and check both pages in a browser. +`site/_redirects` carries the update feed: `/appcast.xml` redirects with a temporary (302) redirect to the `appcast.xml` asset of the latest GitHub Release, which every installed copy of Ask Key requests ([ADR 0011](../docs/adr/0011-in-app-updates.md)). Deleting the file or changing its target breaks updates for every installed copy. Because each deploy replaces the whole site, a deploy from a checkout without it removes the redirect. + ## Search and sharing `site/robots.txt` points crawlers to `site/sitemap.xml`; update its `lastmod` dates when the pages change. `site/_headers` sets Cloudflare Pages response headers: the `askkey.pages.dev` mirror is marked `noindex`, hashed stylesheets, scripts and fonts are cached for a year, and pictures for a week. Each page carries its canonical URL, `hreflang` alternates, Open Graph and X card tags with its own share image (`site/assets/og-*.jpg`), and `SoftwareApplication` structured data. diff --git a/site/_redirects b/site/_redirects new file mode 100644 index 0000000..61fff18 --- /dev/null +++ b/site/_redirects @@ -0,0 +1 @@ +/appcast.xml https://github.com/sudoHG/AskKey/releases/latest/download/appcast.xml 302 From 7a1608db0457f2ddf3ff986fb8b2498fdd058335 Mon Sep 17 00:00:00 2001 From: sudoHG Date: Thu, 5 Nov 2026 17:15:46 +0800 Subject: [PATCH 2/3] Check the appcast signature against the committed public key sign_update --verify derives the public key from the delivered private key, so a key that does not match the app's SUPublicEDKey used to pass packaging. generate-appcast.py now also checks the DMG signature against scripts/sparkle-public-key.txt with CryptoKit through /usr/bin/xcrun swift, which receives only the public key, the signature and the DMG path, and fails before writing anything on a mismatch. --public-key-file exists for tests; packaging never passes it, and the fixture askkey appends it to point at the synthetic pair. The read-back commands in docs/testing.md now print OK or FAILED for every check, including both downloads. --- .../Fixtures/package-release-tool.py | 6 +- Tests/Automation/test_generate_appcast.py | 39 ++++++++++- Tests/Automation/test_package_release.py | 26 ++++++- .../test_package_release_appcast.py | 13 +++- docs/adr/0011-in-app-updates.md | 2 +- docs/testing.md | 14 ++-- scripts/generate-appcast.py | 67 ++++++++++++++++--- 7 files changed, 143 insertions(+), 24 deletions(-) diff --git a/Tests/Automation/Fixtures/package-release-tool.py b/Tests/Automation/Fixtures/package-release-tool.py index 0de8b23..1b2d8c7 100644 --- a/Tests/Automation/Fixtures/package-release-tool.py +++ b/Tests/Automation/Fixtures/package-release-tool.py @@ -108,7 +108,11 @@ environment[variable] = str(key_file) if failure == "delivery-missing": key_file.unlink() - result = subprocess.call(arguments[arguments.index("--") + 1:], env=environment) + command = arguments[arguments.index("--") + 1:] + if sparkle: + # Check against the synthetic pair, not the committed public key. + command += ["--public-key-file", os.environ["PACKAGE_TEST_SPARKLE_PUBLIC_KEY"]] + result = subprocess.call(command, env=environment) sys.exit(result) else: sys.exit(1) diff --git a/Tests/Automation/test_generate_appcast.py b/Tests/Automation/test_generate_appcast.py index 41dd8d5..b355f71 100644 --- a/Tests/Automation/test_generate_appcast.py +++ b/Tests/Automation/test_generate_appcast.py @@ -2,6 +2,7 @@ import base64 from email.utils import parsedate_to_datetime +import importlib.util import os from pathlib import Path import subprocess @@ -11,6 +12,10 @@ ROOT = Path(__file__).resolve().parents[2] SCRIPT = ROOT / "scripts/generate-appcast.py" +SPEC = importlib.util.spec_from_file_location("package_release_keys", + ROOT / "Tests/Automation/test_package_release.py") +package = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(package) SIGN_UPDATE = ROOT / ".build/artifacts/sparkle/Sparkle/bin/sign_update" SPARKLE = "{http://www.andymatuschak.org/xml-namespaces/sparkle}" XML_LANG = "{http://www.w3.org/XML/1998/namespace}lang" @@ -27,6 +32,12 @@ def write_synthetic_key(path): return path +def unchanged_outputs(test, result, directory): + test.assertEqual(result.returncode, 1) + test.assertFalse((directory / "appcast.xml").exists()) + test.assertEqual(list((directory / "scratch").iterdir()), []) + + def verify(path, key, signature=None): arguments = [str(SIGN_UPDATE), "--verify", "--ed-key-file", str(key), str(path)] if signature is not None: @@ -42,21 +53,29 @@ def setUp(self): self.directory = Path(temporary.name) self.scratch = self.directory / "scratch" self.scratch.mkdir() - self.key = write_synthetic_key(self.directory / "sparkle-key") + seed, public_key = package.synthetic_key_pair() + self.key = self.directory / "sparkle-key" + self.key.write_text(seed + "\n", encoding="ascii") + self.public_key = self.directory / "sparkle-public-key.txt" + self.public_key.write_text(public_key + "\n", encoding="ascii") self.dmg = self.directory / f"AskKey-{VERSION}.dmg" self.dmg.write_bytes(os.urandom(4096) + b"synthetic disk image") self.notes = self.directory / "notes.md" self.notes.write_text(RELEASE_NOTES, encoding="utf-8") self.output = self.directory / "appcast.xml" - def run_generator(self, *extra, key=None, notes=None, dmg=None): + def run_generator(self, *extra, key=None, notes=None, dmg=None, public_key=True): environment = {key: value for key, value in os.environ.items() if not key.startswith("ASKKEY_")} environment["TMPDIR"] = str(self.scratch) + if public_key is True: + extra = ("--public-key-file", str(self.public_key), *extra) + elif public_key: + extra = ("--public-key-file", str(public_key), *extra) return subprocess.run( ["python3", str(SCRIPT), str(dmg or self.dmg), "--version", VERSION, "--release-notes", str(notes or self.notes), "--ed-key-file", str(key or self.key), "--output", str(self.output), *extra], - env=environment, cwd=self.directory, text=True, capture_output=True, timeout=60) + env=environment, cwd=self.directory, text=True, capture_output=True, timeout=120) def item(self): channel = ET.parse(self.output).getroot().find("channel") @@ -137,6 +156,20 @@ def test_invalid_release_notes_are_rejected_before_signing(self): text=True, capture_output=True, timeout=60) self.assertEqual((valid.returncode, valid.stdout, valid.stderr), (0, "", "")) + def test_key_that_does_not_match_the_public_key_writes_nothing(self): + # The synthetic key is valid but is not the pair of the committed public key. + result = self.run_generator(public_key=None) + unchanged_outputs(self, result, self.directory) + self.assertIn("The delivered Sparkle key does not match the repository public key", result.stderr) + self.assertNotIn(self.key.read_text(encoding="ascii").strip(), result.stdout + result.stderr) + malformed = self.directory / "malformed-public-key.txt" + malformed.write_text(base64.b64encode(os.urandom(31)).decode("ascii") + "\n", encoding="ascii") + for public_key in (malformed, self.directory / "missing.txt"): + with self.subTest(public_key=public_key.name): + result = self.run_generator(public_key=public_key) + unchanged_outputs(self, result, self.directory) + self.assertIn("must hold one base64-encoded 32-byte EdDSA public key", result.stderr) + def test_invalid_key_is_never_printed_and_writes_nothing(self): key = self.directory / "invalid-key" key.write_text("synthetic-invalid-sparkle-key-text\n", encoding="ascii") diff --git a/Tests/Automation/test_package_release.py b/Tests/Automation/test_package_release.py index a105073..e3f771d 100644 --- a/Tests/Automation/test_package_release.py +++ b/Tests/Automation/test_package_release.py @@ -1,6 +1,7 @@ """Check real synthetic DMG packaging and fail-closed notarization gates.""" import base64 +import functools import hashlib import json import os @@ -23,6 +24,25 @@ NOTARY_CREDENTIAL = "synthetic-notary" SPARKLE_CREDENTIAL = "synthetic-sparkle-key" ENCLOSURE_SIGNATURE = "{http://www.andymatuschak.org/xml-namespaces/sparkle}edSignature" +PUBLIC_KEY_FROM_SEED = ( + "import CryptoKit\nimport Foundation\n" + "let seed = Data(base64Encoded: CommandLine.arguments[1])!\n" + "print(try! Curve25519.Signing.PrivateKey(rawRepresentation: seed)" + ".publicKey.rawRepresentation.base64EncodedString())\n" +) + + +@functools.lru_cache(maxsize=None) +def synthetic_key_pair(): + """A random Ed25519 seed, as Sparkle exports one, and its CryptoKit public key; no keychain.""" + seed = base64.b64encode(os.urandom(32)).decode("ascii") + with tempfile.TemporaryDirectory(prefix="askkey-synthetic-key-pair-") as directory: + script = Path(directory) / "public-key.swift" + script.write_text(PUBLIC_KEY_FROM_SEED, encoding="utf-8") + public_key = subprocess.run( + ["/usr/bin/xcrun", "swift", str(script), seed], env=dict(os.environ, TMPDIR=directory), + check=True, capture_output=True, text=True, timeout=120).stdout.strip() + return seed, public_key SYNTHETIC_MAPPINGS = { "PRIVATE_KEY_FILE": "synthetic-private-key-file", "KEY_ID": "synthetic-key-id", @@ -53,10 +73,11 @@ def setUp(self): self.environment = {key: value for key, value in os.environ.items() if not key.startswith("ASKKEY_") and key not in SYNTHETIC_MAPPINGS} self.environment["TMPDIR"] = str(self.scratch) - # A random Ed25519 seed file, as Sparkle exports one; no keychain item is created. - self.sparkle_key_text = base64.b64encode(os.urandom(32)).decode("ascii") + self.sparkle_key_text, public_key = synthetic_key_pair() self.sparkle_key = self.directory / "sparkle-key" self.sparkle_key.write_text(self.sparkle_key_text + "\n", encoding="ascii") + self.sparkle_public_key = self.directory / "sparkle-public-key.txt" + self.sparkle_public_key.write_text(public_key + "\n", encoding="ascii") self.release_notes = self.directory / "release notes.md" self.release_notes.write_text("\nSynthetic notes.\n\n" "Synthetic Simplified Chinese notes.\n", encoding="utf-8") @@ -250,6 +271,7 @@ def mock_tools(self): "PATH": f"{tools}{os.pathsep}{environment['PATH']}", "PACKAGE_TEST_TRACE": str(trace), "PACKAGE_TEST_SPARKLE_KEY": str(self.sparkle_key), + "PACKAGE_TEST_SPARKLE_PUBLIC_KEY": str(self.sparkle_public_key), "ASKKEY_CODESIGN_IDENTITY": "synthetic-signing-identity", "ASKKEY_APPLE_TEAM_ID": "FAKETEAM01", "ASKKEY_NOTARY_PROFILE": "synthetic-notary-profile", diff --git a/Tests/Automation/test_package_release_appcast.py b/Tests/Automation/test_package_release_appcast.py index 42df979..0b8828b 100644 --- a/Tests/Automation/test_package_release_appcast.py +++ b/Tests/Automation/test_package_release_appcast.py @@ -1,7 +1,9 @@ """Check the signed appcast step of release packaging with synthetic tools and keys.""" +import base64 import importlib.util import json +import os from pathlib import Path import plistlib import subprocess @@ -114,16 +116,23 @@ def test_appcast_runs_through_askkey_and_critical_writes_critical_update(self): def test_appcast_failures_write_nothing(self): harness = self.harness environment, trace = harness.mock_tools() - for failure in ("sparkle-approval-denied", "invalid-key"): + # A valid Ed25519 seed that is not the pair of the public key packaging checks. + mismatched = base64.b64encode(os.urandom(32)).decode("ascii") + for failure in ("sparkle-approval-denied", "invalid-key", "mismatched-key"): with self.subTest(failure=failure): environment["PACKAGE_TEST_FAILURE"] = failure if failure == "invalid-key": harness.sparkle_key.write_text("synthetic-invalid-sparkle-key\n", encoding="ascii") + elif failure == "mismatched-key": + harness.sparkle_key.write_text(mismatched + "\n", encoding="ascii") result = harness.run_script(notarize=True, environment=environment) self.assertNotEqual(result.returncode, 0) self.assertIn("Cannot write the signed appcast", result.stderr) + if failure == "mismatched-key": + self.assertIn("does not match the repository public key", result.stderr) harness.assert_settings_not_printed(result, environment) - self.assertNotIn("synthetic-invalid-sparkle-key", result.stdout + result.stderr) + for value in ("synthetic-invalid-sparkle-key", mismatched): + self.assertNotIn(value, result.stdout + result.stderr) self.assertEqual(sum(call[0] == "askkey" for call in self.calls(trace)), 1) harness.assert_no_artifacts() trace.unlink() diff --git a/docs/adr/0011-in-app-updates.md b/docs/adr/0011-in-app-updates.md index 9357ead..f39a062 100644 --- a/docs/adr/0011-in-app-updates.md +++ b/docs/adr/0011-in-app-updates.md @@ -26,7 +26,7 @@ Users install Ask Key by replacing `/Applications/Ask Key.app` with the app from **Packaging.** `scripts/build-app.sh` embeds `Sparkle.framework` in `Contents/Frameworks` in every mode, so the binary always launches, and removes its `XPCServices`, which a non-sandboxed app does not need. It makes sure the executable's rpath includes `@executable_path/../Frameworks` and removes rpaths into the checkout or derived data, so dyld loads only the embedded framework and no local path ships. It signs nested code inside out with the existing options (Sparkle's `Autoupdate`, `Updater.app`, the framework, the helper and its resource bundle, then the app) without `--deep`, which is kept for verification. Only `Release` mode writes the Sparkle keys from decisions 2 and 3; Debug, `Local` and E2E bundles carry none. The app stays unsandboxed. -**Publication.** Every notarized release writes `appcast.xml` with [generate-appcast.py](../../scripts/generate-appcast.py) and publishes it as a Release asset ([ADR 0006](0006-release-process.md)); until a release carries it, the redirect target returns 404. The feed has one item. `sparkle:version` and `sparkle:shortVersionString` are the product version, which packaging requires to equal the app's `CFBundleVersion`; `sparkle:minimumSystemVersion` is `14.0.0`; the enclosure is the versioned DMG URL from decision 2 with its `length` and `sparkle:edSignature`; `--critical` adds an empty `sparkle:criticalUpdate`, which marks the update critical for every older version. The release notes are embedded in the signed feed as Markdown `description` elements in English and Simplified Chinese (`xml:lang` `en` and `zh-Hans`). Sparkle 2.10.1 picks the one that best matches the macOS preferred languages (`SUAppcast.m:237-276`) and renders Markdown in a text view, not a web view (`SUUpdateAlert.m:181-195`, `SUUpdateAlert.m:333-339`). There is no `sparkle:releaseNotesLink`, so showing the notes fetches nothing; `sparkle:fullReleaseNotesLink` names the GitHub Release and is only opened in the browser when the user clicks Version History (`SPUStandardUserDriver.m:679-685`). `sign_update` from the resolved SwiftPM artifact signs the DMG and then the feed with the key file that Ask Key delivers in `SPARKLE_ED_KEY_FILE` to one `askkey run --wait-for-approval` request, and verifies both before the feed is written. Its output, which can echo key material, is never shown, and no keychain item is created. +**Publication.** Every notarized release writes `appcast.xml` with [generate-appcast.py](../../scripts/generate-appcast.py) and publishes it as a Release asset ([ADR 0006](0006-release-process.md)); until a release carries it, the redirect target returns 404. The feed has one item. `sparkle:version` and `sparkle:shortVersionString` are the product version, which packaging requires to equal the app's `CFBundleVersion`; `sparkle:minimumSystemVersion` is `14.0.0`; the enclosure is the versioned DMG URL from decision 2 with its `length` and `sparkle:edSignature`; `--critical` adds an empty `sparkle:criticalUpdate`, which marks the update critical for every older version. The release notes are embedded in the signed feed as Markdown `description` elements in English and Simplified Chinese (`xml:lang` `en` and `zh-Hans`). Sparkle 2.10.1 picks the one that best matches the macOS preferred languages (`SUAppcast.m:237-276`) and renders Markdown in a text view, not a web view (`SUUpdateAlert.m:181-195`, `SUUpdateAlert.m:333-339`). There is no `sparkle:releaseNotesLink`, so showing the notes fetches nothing; `sparkle:fullReleaseNotesLink` names the GitHub Release and is only opened in the browser when the user clicks Version History (`SPUStandardUserDriver.m:679-685`). `sign_update` from the resolved SwiftPM artifact signs the DMG and then the feed with the key file that Ask Key delivers in `SPARKLE_ED_KEY_FILE` to one `askkey run --wait-for-approval` request, and verifies both before the feed is written. Because `sign_update --verify` derives the public key from the delivered private key, the generator also checks the DMG signature against `scripts/sparkle-public-key.txt` with CryptoKit, so a delivered key that does not match the app's `SUPublicEDKey` fails packaging and nothing is written. `sign_update` output, which can echo key material, is never shown, and no keychain item is created. Sparkle follows the website's redirect to GitHub and GitHub's redirects to its asset host. The downloader runs in process because the Info.plist does not set `SUEnableDownloaderService` (`SPUDownloadDriver.m:58-60`, `SPUDownloadDriver.m:108-112`, `SPUXPCServiceInfo.m:16-22`) and uses a default `NSURLSession` whose delegate does not implement `URLSession:task:willPerformHTTPRedirection:newRequest:completionHandler:` (`SPUDownloader.m:27`, `SPUDownloader.m:83-86`; the method appears nowhere in Sparkle's `Downloader` or `Sparkle` sources), so `NSURLSession` follows redirects itself. Only the initial URL's scheme is checked (`SPUDownloader.m:65-81`), the status check sees the final response (`SPUDownloader.m:191-211`), and relative appcast URLs resolve against the final URL (`SPUDownloader.m:214-224`, `SUAppcastDriver.m:185`); the feed uses absolute URLs. No host is trusted for integrity: the feed and DMG signatures are. diff --git a/docs/testing.md b/docs/testing.md index 9e3a8df..b38feb9 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -140,7 +140,7 @@ Notarized runs also need `--sparkle-key-credential `, the Ask Key credenti askkey run --wait-for-approval --credential --operation-id --caller-name "AskKey release" --caller-purpose "Sign the AskKey update feed" -- /bin/bash -c 'set +x; set -eu; exec python3 "$@" --ed-key-file "$SPARKLE_ED_KEY_FILE"' bash scripts/generate-appcast.py --version --release-notes --output /appcast.xml [--critical] ``` -[generate-appcast.py](../scripts/generate-appcast.py) signs the stapled DMG and then the feed with `sign_update` from `.build/artifacts/sparkle/Sparkle/bin`, the SwiftPM artifact pinned by `Package.resolved` (present after `swift build` or `swift package resolve`), and verifies both before writing the feed. The key path expands only in the target shell, `sign_update` output is never shown because it can echo key material, and no keychain item is read or created. [ADR 0011](adr/0011-in-app-updates.md) (Publication) describes the feed's item. Automation tests use only synthetic Ed25519 seed files in temporary directories and the fixture `askkey`. +[generate-appcast.py](../scripts/generate-appcast.py) signs the stapled DMG and then the feed with `sign_update` from `.build/artifacts/sparkle/Sparkle/bin`, the SwiftPM artifact pinned by `Package.resolved` (present after `swift build` or `swift package resolve`), and verifies both before writing the feed. `sign_update --verify` derives the public key from the delivered private key, so the generator also checks the DMG signature against `scripts/sparkle-public-key.txt` with CryptoKit (`/usr/bin/xcrun swift`, which receives only the public key, the signature and the DMG path); a delivered key that does not match the key built into the app fails packaging before anything is written. Its `--public-key-file` option exists for tests, and packaging never passes it. The key path expands only in the target shell, `sign_update` output is never shown because it can echo key material, and no keychain item is read or created. [ADR 0011](adr/0011-in-app-updates.md) (Publication) describes the feed's item. Automation tests use only synthetic Ed25519 seed files in temporary directories and the fixture `askkey`. The release-notes file is UTF-8 Markdown with two sections, each starting with its marker line: `` for English, then `` for Simplified Chinese, with nothing but blank lines before the first marker. Both are embedded in the signed feed, and the update window shows the one that best matches the Mac's preferred languages, so describe what changed for the user and leave download and install steps to the GitHub Release. Release notes for a release that migrates the library state its downgrade limit in both sections. @@ -165,18 +165,18 @@ CI cannot check focus, Touch ID or real clients. Before notarizing, review the r - Clicking the menu-bar item with the management window open behind another app. - The sample prompt from Agent access runs as written in a new client session; each supported client that is installed still reports Connected; Claude Code setup still detects its discovery hooks; an SSH request is reminded to look up credentials first. - After notarization, verify both checksum files in the output directory with `shasum -a 256 -c AskKey-.dmg.sha256` and `shasum -a 256 -c AskKey.dmg.sha256` (replace `` with the release version). Upload all five files (`AskKey-.dmg`, `AskKey-.dmg.sha256`, `AskKey.dmg`, `AskKey.dmg.sha256`, and `appcast.xml`) to the draft release. Obtain the maintainer's explicit approval before pushing the tag or publishing the release, as required by [ADR 0006](adr/0006-release-process.md). Publishing the release makes the feed live through the website's redirect to `releases/latest/download/appcast.xml`; no website deploy is needed. -- Right after publishing, read the feed back. `releases/latest` is the newest published release that is not a pre-release, so a release published without `appcast.xml` makes the feed return 404, and every installed copy silently stops seeing updates until the asset is uploaded. `sign_update --verify` derives the public key from the private key file, so the signature check runs through the Sparkle key credential: +- Right after publishing, read the feed back. `releases/latest` is the newest published release that is not a pre-release, so a release published without `appcast.xml` makes the feed return 404, and every installed copy silently stops seeing updates until the asset is uploaded. `sign_update --verify` derives the public key from the private key file, so the signature check runs through the Sparkle key credential. Paste the commands into an interactive terminal; each check prints `OK:` or `FAILED:`, and any `FAILED:` line means the feed is not usable: ```bash VERSION="$(bash scripts/product-version.sh)" FEED_DIR="$(mktemp -d)" - curl -fsSL -o "$FEED_DIR/appcast.xml" https://askkey.sudohg.com/appcast.xml - "/Applications/Ask Key.app/Contents/Helpers/askkey" run --wait-for-approval --credential --operation-id "$(uuidgen)" --caller-name "AskKey release" --caller-purpose "Verify the AskKey $VERSION update feed" -- /bin/bash -c 'set +x; set -eu; "$1" --verify --ed-key-file "$SPARKLE_ED_KEY_FILE" "$2" >/dev/null 2>&1' bash "$PWD/.build/artifacts/sparkle/Sparkle/bin/sign_update" "$FEED_DIR/appcast.xml" && echo "Feed signature verified" - grep -Fq "$VERSION" "$FEED_DIR/appcast.xml" && echo "Feed version $VERSION" + curl -fsSL -o "$FEED_DIR/appcast.xml" https://askkey.sudohg.com/appcast.xml && echo "OK: downloaded the feed" || echo "FAILED: cannot download the feed" + "/Applications/Ask Key.app/Contents/Helpers/askkey" run --wait-for-approval --credential --operation-id "$(uuidgen)" --caller-name "AskKey release" --caller-purpose "Verify the AskKey $VERSION update feed" -- /bin/bash -c 'set +x; set -eu; "$1" --verify --ed-key-file "$SPARKLE_ED_KEY_FILE" "$2" >/dev/null 2>&1' bash "$PWD/.build/artifacts/sparkle/Sparkle/bin/sign_update" "$FEED_DIR/appcast.xml" && echo "OK: feed signature verified" || echo "FAILED: feed signature did not verify" + grep -Fq "$VERSION" "$FEED_DIR/appcast.xml" && echo "OK: feed version $VERSION" || echo "FAILED: feed version is not $VERSION" URL="$(sed -n 's/.*/dev/null)" = "$LENGTH" ] && echo "OK: enclosure length $LENGTH" || echo "FAILED: enclosure length does not match the feed" rm -rf "$FEED_DIR" ``` diff --git a/scripts/generate-appcast.py b/scripts/generate-appcast.py index d96d341..101e0c6 100644 --- a/scripts/generate-appcast.py +++ b/scripts/generate-appcast.py @@ -2,8 +2,9 @@ """Write a signed Sparkle appcast with one item for a notarized release DMG. The DMG and the feed are signed with Sparkle's sign_update from the resolved -SwiftPM artifact and verified before the feed is written. Release notes are -embedded in the signed feed in English and Simplified Chinese. +SwiftPM artifact and verified before the feed is written, and the DMG signature +is checked against the committed public key. Release notes are embedded in the +signed feed in English and Simplified Chinese. """ import argparse @@ -22,7 +23,23 @@ ROOT = Path(__file__).resolve().parents[1] # SwiftPM resolves this artifact at the version pinned by Package.resolved. SIGN_UPDATE = ROOT / ".build/artifacts/sparkle/Sparkle/bin/sign_update" -SPARKLE = "http://www.andymatuschak.org/xml-namespaces/sparkle" +PUBLIC_KEY_FILE = ROOT / "scripts/sparkle-public-key.txt" +# sign_update --verify derives the public key from the private key, so check the +# signature against the key the app ships with. CryptoKit sees no private key. +PUBLIC_KEY_CHECK = """\ +import CryptoKit +import Foundation + +let arguments = CommandLine.arguments +guard arguments.count == 4, + let rawKey = Data(base64Encoded: arguments[1]), + let signature = Data(base64Encoded: arguments[2]), + let publicKey = try? Curve25519.Signing.PublicKey(rawRepresentation: rawKey), + let archive = try? Data(contentsOf: URL(fileURLWithPath: arguments[3]), options: .mappedIfSafe) +else { exit(2) } +exit(publicKey.isValidSignature(signature, for: archive) ? 0 : 1) +""" +SPARKLE ="http://www.andymatuschak.org/xml-namespaces/sparkle" XML_LANG = "{http://www.w3.org/XML/1998/namespace}lang" RELEASES = "https://github.com/sudoHG/AskKey/releases" MINIMUM_SYSTEM_VERSION = "14.0.0" @@ -79,6 +96,37 @@ def sign_archive(archive, key_file): return signature +def read_public_key(path): + try: + key = "".join(Path(path).read_text(encoding="ascii").split()) + valid = len(base64.b64decode(key, validate=True)) == 32 + except (OSError, UnicodeDecodeError, binascii.Error, ValueError): + valid = False + if not valid: + raise Failure("The public key file must hold one base64-encoded 32-byte EdDSA public key") + return key + + +def check_public_key(public_key, signature, archive, directory): + script = directory / "check-public-key.swift" + script.write_text(PUBLIC_KEY_CHECK, encoding="utf-8") + # The Swift interpreter leaves directories in TMPDIR; keep them in ours. + scratch = directory / "swift" + scratch.mkdir() + environment = {key: value for key, value in os.environ.items() if key != "SPARKLE_ED_KEY_FILE"} + environment["TMPDIR"] = str(scratch) + try: + result = subprocess.run(["/usr/bin/xcrun", "swift", str(script), public_key, signature, + str(archive)], env=environment, stdin=subprocess.DEVNULL, + capture_output=True) + except OSError: + raise Failure("Cannot run Swift to check the DMG signature against the public key") from None + if result.returncode == 1: + raise Failure("The delivered Sparkle key does not match the repository public key") + if result.returncode != 0: + raise Failure("Cannot check the DMG signature against the public key") + + def sparkle_element(parent, name, text=None): element = ET.SubElement(parent, f"{{{SPARKLE}}}{name}") element.text = text @@ -113,7 +161,7 @@ def feed(version, length, signature, notes, critical, published): return ET.tostring(rss, encoding="utf-8", xml_declaration=True) + b"\n" -def write_appcast(dmg, version, notes_path, key_file, output, critical): +def write_appcast(dmg, version, notes_path, key_file, output, critical, public_key_file): if not VERSION.fullmatch(version): raise Failure("Version must match X.Y.Z with numeric components") if dmg.name != f"AskKey-{version}.dmg" or not dmg.is_file(): @@ -121,14 +169,16 @@ def write_appcast(dmg, version, notes_path, key_file, output, critical): if os.path.lexists(output): raise Failure("The appcast output already exists") notes = read_release_notes(notes_path) + public_key = read_public_key(public_key_file) if not (SIGN_UPDATE.is_file() and os.access(SIGN_UPDATE, os.X_OK)): raise Failure("Sparkle sign_update was not found; run `swift package resolve` first") if not (key_file.is_file() and os.access(key_file, os.R_OK)): raise Failure("The Sparkle private key file is missing or unreadable") - signature = sign_archive(dmg, key_file) - data = feed(version, dmg.stat().st_size, signature, notes, critical, - datetime.now(timezone.utc).replace(microsecond=0)) with tempfile.TemporaryDirectory(prefix="askkey-appcast-") as directory: + signature = sign_archive(dmg, key_file) + check_public_key(public_key, signature, dmg, Path(directory)) + data = feed(version, dmg.stat().st_size, signature, notes, critical, + datetime.now(timezone.utc).replace(microsecond=0)) # sign_update treats a path ending in .xml as a feed and embeds the signature. unsigned = Path(directory) / "appcast.xml" unsigned.write_bytes(data) @@ -155,9 +205,10 @@ def main(argv=None): parser.add_argument("--ed-key-file", type=Path, required=True) parser.add_argument("--output", type=Path, required=True) parser.add_argument("--critical", action="store_true") + parser.add_argument("--public-key-file", type=Path, default=PUBLIC_KEY_FILE) args = parser.parse_args(arguments) write_appcast(args.dmg, args.version, args.release_notes, args.ed_key_file, - args.output, args.critical) + args.output, args.critical, args.public_key_file) return 0 except Failure as error: print(f"Error: {error}", file=sys.stderr) From e529ed5cbbe8ba3e61d7b3c0cf00a0eb3d1e6913 Mon Sep 17 00:00:00 2001 From: sudoHG Date: Thu, 5 Nov 2026 17:25:35 +0800 Subject: [PATCH 3/3] Give a public key mismatch its own exit code The embedded Swift check exited with 1 on a signature mismatch, which is also the exit code of a Swift compile failure, so a toolchain problem would have been reported as a mismatched Sparkle key. A mismatch now exits with 3, and every other non-zero code reports that the signature could not be checked. Also fix the spacing of the SPARKLE constant. --- scripts/generate-appcast.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/scripts/generate-appcast.py b/scripts/generate-appcast.py index 101e0c6..bc3c791 100644 --- a/scripts/generate-appcast.py +++ b/scripts/generate-appcast.py @@ -37,9 +37,10 @@ let publicKey = try? Curve25519.Signing.PublicKey(rawRepresentation: rawKey), let archive = try? Data(contentsOf: URL(fileURLWithPath: arguments[3]), options: .mappedIfSafe) else { exit(2) } -exit(publicKey.isValidSignature(signature, for: archive) ? 0 : 1) +// 3, not 1: a Swift compile failure also exits with 1. +exit(publicKey.isValidSignature(signature, for: archive) ? 0 : 3) """ -SPARKLE ="http://www.andymatuschak.org/xml-namespaces/sparkle" +SPARKLE = "http://www.andymatuschak.org/xml-namespaces/sparkle" XML_LANG = "{http://www.w3.org/XML/1998/namespace}lang" RELEASES = "https://github.com/sudoHG/AskKey/releases" MINIMUM_SYSTEM_VERSION = "14.0.0" @@ -121,7 +122,7 @@ def check_public_key(public_key, signature, archive, directory): capture_output=True) except OSError: raise Failure("Cannot run Swift to check the DMG signature against the public key") from None - if result.returncode == 1: + if result.returncode == 3: raise Failure("The delivered Sparkle key does not match the repository public key") if result.returncode != 0: raise Failure("Cannot check the DMG signature against the public key")