fix(release): restore Mac signing keychain search

This commit is contained in:
2026-10-17 08:27:14 -04:00
parent e0670bede5
commit fb0c34cd70
2 changed files with 117 additions and 34 deletions
+67 -34
View File
@@ -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))
+50
View File
@@ -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))