From fb0c34cd70c3a8f1d84a2700fdbfd49cf7b0ec7e Mon Sep 17 00:00:00 2001 From: jwinterm Date: Sat, 17 Oct 2026 08:27:14 -0400 Subject: [PATCH] fix(release): restore Mac signing keychain search --- contrib/release/sign_macos.py | 101 +++++++++++++++-------- contrib/release/tests/test_sign_macos.py | 50 +++++++++++ 2 files changed, 117 insertions(+), 34 deletions(-) diff --git a/contrib/release/sign_macos.py b/contrib/release/sign_macos.py index 7d1f3cc6..0a682717 100644 --- a/contrib/release/sign_macos.py +++ b/contrib/release/sign_macos.py @@ -5,10 +5,12 @@ https://github.com/apple-oss-distributions/SecurityTool/blob/main/security.c Output stays captured because native diagnostics may repeat supplied input. """ import base64 +from contextlib import contextmanager import json import os from pathlib import Path import secrets +import shlex import subprocess import sys import tempfile @@ -34,7 +36,7 @@ def child_environment(): def security(operation, *values): if operation not in { "create-keychain", "set-keychain-settings", "unlock-keychain", "import", - "set-key-partition-list", "delete-keychain", + "set-key-partition-list", "delete-keychain", "list-keychains", }: raise ValueError("Keychain operation is outside candidate signing") command = " ".join(argument(value) for value in (operation, *values)) + "\n" @@ -56,6 +58,8 @@ def security(operation, *values): "native keychain operation returned an error") raise RuntimeError(f"{operation}: {cause} (exit {result.returncode}); raw diagnostics withheld") + return result.stdout + def run(arguments, *, timeout=120): result = subprocess.run(arguments, text=True, capture_output=True, @@ -66,6 +70,14 @@ def run(arguments, *, timeout=120): "User interaction is not allowed": "signing key requires interactive access", "errSecInternalComponent": "keychain denied access to the signing identity", "not signed at all": "candidate contains an unsigned component", + "resource fork, Finder information": "candidate contains metadata forbidden by code signing", + "unable to build chain": "signing certificate chain could not be built", + "unsealed contents present": "embedded bundle has unsealed root contents", + "bundle format unrecognized": "component has an unsupported bundle format", + "timestamp service": "Apple timestamp service failed", + "Timestamping service": "Apple timestamp service failed", + "certificate has expired": "signing certificate has expired", + "CSSMERR_TP_CERT_EXPIRED": "signing certificate has expired", } cause = next((cause for marker, cause in causes.items() if marker in result.stderr), "native signing operation returned an error") @@ -73,6 +85,27 @@ def run(arguments, *, timeout=120): return result.stdout +@contextmanager +def keychain_search(keychain): + previous = [value for value in shlex.split(security("list-keychains", "-d", "user")) + if Path(value) != keychain] + if not previous or any(not Path(value).is_absolute() for value in previous): + raise RuntimeError("Existing user keychain search list is absent or malformed") + try: + security("list-keychains", "-d", "user", "-s", str(keychain), *previous) + yield + finally: + security("list-keychains", "-d", "user", "-s", *previous) + + +def signable_files(app): + for path in sorted(app.rglob("*")): + if path.is_symlink() or not path.is_file() or any(part.endswith(".framework") for part in path.parts): + continue + if "Mach-O" in run(["/usr/bin/file", "-b", str(path)]): + yield path + + def private_file(path, content): descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) with os.fdopen(descriptor, "wb") as stream: @@ -115,40 +148,40 @@ def main(): security("set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", password, str(keychain)) - def sign(path): - run(["/usr/bin/codesign", "--force", "--options", "runtime", "--timestamp", - "--keychain", str(keychain), "--sign", - credentials["APPLE_SIGNING_IDENTITY"], str(path)]) + with keychain_search(keychain): + def sign(path): + print("Signing component: " + (str(path.relative_to(app)) if path != app else "Wowlet.app"), flush=True) + run(["/usr/bin/codesign", "--force", "--options", "runtime", "--timestamp", + "--keychain", str(keychain), "--sign", + credentials["APPLE_SIGNING_IDENTITY"], str(path)]) - for path in sorted(app.rglob("*")): - if path.is_file() and not any(part.endswith(".framework") for part in path.parts): - if "Mach-O" in run(["/usr/bin/file", "-b", str(path)]): - sign(path) - frameworks = app / "Contents/Frameworks" - for path in sorted(frameworks.glob("*.framework")): - sign(path) - sign(app) - run(["/usr/bin/codesign", "--verify", "--deep", "--strict", str(app)]) - archive = workspace / "candidate.zip" - run(["/usr/bin/ditto", "-c", "-k", "--keepParent", str(app), str(archive)]) - raw = run(["/usr/bin/xcrun", "notarytool", "submit", str(archive), - "--key", str(notary_key), "--key-id", credentials["APPLE_API_KEY"], - "--issuer", credentials["APPLE_API_ISSUER"], "--wait", "--timeout", "30m", - "--output-format", "json"], timeout=1900) - response = json.loads(raw) - if response.get("status") != "Accepted": - raise RuntimeError("Apple notarization did not return Accepted") - try: - uuid.UUID(response.get("id", "")) - except (ValueError, TypeError, AttributeError): - raise RuntimeError("Apple notarization submission identity is missing or malformed") from None - run(["/usr/bin/xcrun", "stapler", "staple", str(app)]) - run(["/usr/bin/xcrun", "stapler", "validate", str(app)]) - private_file(app.parent / "macos-signing.json", json.dumps({ - "developer_id_verified": True, "notarization_status": "Accepted", - "notary_submission_id": response.get("id"), "staple_validated": True, - }, sort_keys=True).encode()) - print("Developer ID signature verified; notarization accepted; staple validated") + for path in signable_files(app): + sign(path) + frameworks = app / "Contents/Frameworks" + for path in sorted(frameworks.glob("*.framework")): + sign(path) + sign(app) + run(["/usr/bin/codesign", "--verify", "--deep", "--strict", str(app)]) + archive = workspace / "candidate.zip" + run(["/usr/bin/ditto", "-c", "-k", "--keepParent", str(app), str(archive)]) + raw = run(["/usr/bin/xcrun", "notarytool", "submit", str(archive), + "--key", str(notary_key), "--key-id", credentials["APPLE_API_KEY"], + "--issuer", credentials["APPLE_API_ISSUER"], "--wait", "--timeout", "30m", + "--output-format", "json"], timeout=1900) + response = json.loads(raw) + if response.get("status") != "Accepted": + raise RuntimeError("Apple notarization did not return Accepted") + try: + uuid.UUID(response.get("id", "")) + except (ValueError, TypeError, AttributeError): + raise RuntimeError("Apple notarization submission identity is missing or malformed") from None + run(["/usr/bin/xcrun", "stapler", "staple", str(app)]) + run(["/usr/bin/xcrun", "stapler", "validate", str(app)]) + private_file(app.parent / "macos-signing.json", json.dumps({ + "developer_id_verified": True, "notarization_status": "Accepted", + "notary_submission_id": response.get("id"), "staple_validated": True, + }, sort_keys=True).encode()) + print("Developer ID signature verified; notarization accepted; staple validated") finally: if created: security("delete-keychain", str(keychain)) diff --git a/contrib/release/tests/test_sign_macos.py b/contrib/release/tests/test_sign_macos.py index 0ece75eb..07dc69e5 100644 --- a/contrib/release/tests/test_sign_macos.py +++ b/contrib/release/tests/test_sign_macos.py @@ -2,6 +2,7 @@ import importlib.util from pathlib import Path from types import SimpleNamespace import unittest +import tempfile from unittest.mock import patch spec = importlib.util.spec_from_file_location("sign_macos", Path(__file__).parents[1] / "sign_macos.py") @@ -43,3 +44,52 @@ class MacSigningTests(unittest.TestCase): with self.assertRaises(ValueError): signing.security("dump-keychain", "login.keychain") execute.assert_not_called() + + def test_user_keychain_search_is_restored_after_signing_failure(self): + previous = ['/Users/runner/Library/Keychains/login.keychain-db', '/Library/Keychains/System.keychain'] + calls = [] + def security(operation, *values): + calls.append((operation, values)) + return ' '.join('"' + value + '"' for value in previous) + with patch.object(signing, "security", security): + with self.assertRaisesRegex(RuntimeError, "synthetic signing failure"): + with signing.keychain_search(Path('/private/candidate.keychain-db')): + self.assertEqual(calls[-1][1][-len(previous):], tuple(previous)) + self.assertIn('/private/candidate.keychain-db', calls[-1][1]) + raise RuntimeError('synthetic signing failure') + self.assertEqual(calls[-1], ('list-keychains', ('-d', 'user', '-s', *previous))) + + def test_failed_search_list_write_still_restores_original_list(self): + calls = [] + previous = '/Users/runner/Library/Keychains/login.keychain-db' + def security(operation, *values): + calls.append((operation, values)) + if '/private/candidate.keychain-db' in values: + raise RuntimeError('synthetic search write failure') + return '"' + previous + '"' + with patch.object(signing, "security", security): + with self.assertRaises(RuntimeError): + with signing.keychain_search(Path('/private/candidate.keychain-db')): + self.fail('failed search-list setup entered signing') + self.assertEqual(calls[-1][1], ('-d', 'user', '-s', previous)) + + def test_signing_walk_skips_symlink_aliases_and_framework_internals(self): + with tempfile.TemporaryDirectory() as temporary: + app = Path(temporary) + executable = app / 'actual.dylib' + executable.write_bytes(b'mach-o fixture') + (app / 'alias.dylib').symlink_to(executable.name) + framework = app / 'QtCore.framework' + framework.mkdir() + (framework / 'QtCore').write_bytes(b'framework fixture') + with patch.object(signing, 'run', return_value='Mach-O arm64'): + self.assertEqual(list(signing.signable_files(app)), [executable]) + + def test_native_failure_cause_does_not_echo_untrusted_diagnostics(self): + sentinel = 'must-not-echo-native-input' + result = SimpleNamespace(returncode=1, stdout='', stderr='unable to build chain ' + sentinel) + with patch.object(signing.subprocess, 'run', return_value=result): + with self.assertRaises(RuntimeError) as caught: + signing.run(['/usr/bin/codesign', 'public-component']) + self.assertIn('certificate chain', str(caught.exception)) + self.assertNotIn(sentinel, str(caught.exception))