Integrate macOS code signing into CMake

Moves code signing from the release-tool to CMake and unifies the Windows-equivalent code.
This commit is contained in:
Janek Bevendorff 2025-11-15 02:02:59 +01:00 committed by Jonathan White
parent c09ba0113b
commit 4e59c1c579
8 changed files with 301 additions and 232 deletions

View file

@ -342,6 +342,48 @@ def _capture_vs_env(arch='amd64'):
return env
def _macos_get_codesigning_identity(user_choice=None):
"""
Select an Apple codesigning certificate to be used for signing the macOS binaries.
If only one identity was found on the system, it is returned automatically. If multiple identities are
found, an interactive selection is shown. A user choice can be supplied to skip the selection.
If the user choice refers to an invalid identity, an error is raised.
"""
Check.check_xcode_setup()
result = _run(['security', 'find-identity', '-v', '-p', 'codesigning'], cwd=None, text=True)
identities = [i.strip() for i in result.stdout.strip().split('\n')[:-1]]
identities = [i.split(' ', 2)[1:] for i in identities]
if not identities:
raise Error('No codesigning identities found.')
if not user_choice and len(identities) == 1:
logger.info('Using codesigning identity %s.', identities[0][1])
return identities[0][0]
elif not user_choice:
return identities[_choice_prompt(
'The following code signing identities were found. Which one do you want to use?',
[' '.join(i) for i in identities])][0]
else:
for i in identities:
# Exact match of ID or substring match of description
if user_choice == i[0] or user_choice in i[1]:
return i[0]
raise Error('Invalid identity: %s', user_choice)
def _macos_validate_keychain_profile(keychain_profile):
"""
Validate that a given keychain profile with stored notarization credentials exists and is valid.
If no such profile is found, an error is raised with instructions on how to set one up.
"""
if _run(['security', 'find-generic-password', '-a',
f'com.apple.gke.notary.tool.saved-creds.{keychain_profile}'], cwd=None, check=False).returncode != 0:
raise Error(f'Keychain profile "%s" not found! Run\n'
f' {fmt.bold("xcrun notarytool store-credentials %s [...]" % keychain_profile)}\n'
f'to store your Apple notary service credentials in a keychain as "%s".',
keychain_profile, keychain_profile)
###########################################################################################
# CLI Commands
###########################################################################################
@ -585,6 +627,13 @@ class Build(Command):
help='macOS deployment target version (default: %(default)s).')
parser.add_argument('-p', '--platform-target', default=platform.uname().machine,
help='Build target platform (default: %(default)s).', choices=['x86_64', 'arm64'])
parser.add_argument('--sign', help='Sign binaries prior to packaging.', action='store_true')
parser.add_argument('--sign-identity',
help='Apple Developer identity name used for signing binaries (default: ask).')
parser.add_argument('--notarize', help='Notarize signed file(s).', action='store_true')
parser.add_argument('--keychain-profile', default='notarization-creds',
help='Read Apple credentials for notarization from a keychain (default: %(default)s).')
parser.set_defaults(cmake_generator='Ninja')
elif sys.platform == 'linux':
parser.add_argument('-d', '--docker-image', help='Run build in Docker image (overrides --use-system-deps).')
parser.add_argument('-p', '--platform-target', help='Build target platform (default: %(default)s).',
@ -594,8 +643,10 @@ class Build(Command):
parser.add_argument('-p', '--platform-target', help='Build target platform (default: %(default)s).',
choices=['amd64', 'arm64'], default='amd64')
parser.add_argument('--sign', help='Sign binaries prior to packaging.', action='store_true')
parser.add_argument('--sign-cert', help='SHA1 fingerprint of the signing certificate (optional).')
parser.set_defaults(cmake_generator='Ninja', no_source_tarball=True)
parser.add_argument('--sign-identity', help='SHA1 fingerprint of the signing certificate.')
parser.add_argument('--sign-timestamp-url', help='Timestamp URL for signing binaries.',
default='http://timestamp.sectigo.com')
parser.set_defaults(cmake_generator='Ninja')
parser.add_argument('-c', '--cmake-opts', nargs=argparse.REMAINDER,
help='Additional CMake options (no other arguments can be specified after this).')
@ -674,15 +725,15 @@ class Build(Command):
# noinspection PyMethodMayBeStatic
def build_windows(self, version, src_dir, output_dir, *, parallelism, cmake_opts, platform_target,
sign, sign_cert, with_tests, **_):
sign, sign_identity, sign_timestamp_url, with_tests, **_):
# Check for required tools
if not _cmd_exists('candle.exe') or not _cmd_exists('light.exe') or not _cmd_exists('heat.exe'):
raise Error('WiX Toolset not found on the PATH (candle.exe, light.exe, heat.exe).')
# Setup build signing if requested
if sign:
cmake_opts.append('-DWITH_XC_SIGNINSTALL=ON')
cmake_opts.append(f'-DWITH_XC_SIGNINSTALL_CERT={sign_cert}')
cmake_opts.append(f'-DWITH_XC_CODESIGN_IDENTITY={sign_identity}')
cmake_opts.append(f'-WITH_XC_CODESIGN_TIMESTAMP_URL={sign_timestamp_url}')
# Use vcpkg for dependency deployment
cmake_opts.append('-DX_VCPKG_APPLOCAL_DEPS_INSTALL=ON')
@ -716,13 +767,20 @@ class Build(Command):
# noinspection PyMethodMayBeStatic
def build_macos(self, version, src_dir, output_dir, *, use_system_deps, parallelism, cmake_opts,
macos_target, platform_target, with_tests, **_):
macos_target, platform_target, with_tests, sign, sign_identity, notarize, keychain_profile, **_):
if not use_system_deps:
cmake_opts.append(f'-DVCPKG_TARGET_TRIPLET={platform_target.replace("86_", "")}-osx-dynamic-release')
cmake_opts.append(f'-DCMAKE_OSX_DEPLOYMENT_TARGET={macos_target}')
cmake_opts.append(f'-DCMAKE_OSX_ARCHITECTURES={platform_target}')
with tempfile.TemporaryDirectory() as build_dir:
if sign:
sign_identity = _macos_get_codesigning_identity(sign_identity)
cmake_opts.append(f'-DWITH_XC_CODESIGN_IDENTITY={sign_identity}')
if notarize:
_macos_validate_keychain_profile(keychain_profile)
cmake_opts.append(f'-DWITH_XC_NOTARY_KEYCHAIN_PROFILE={keychain_profile}')
logger.info('Configuring build...')
_run(['cmake', *cmake_opts, str(src_dir)], cwd=build_dir, capture_output=False)
@ -736,9 +794,13 @@ class Build(Command):
_run(['cpack', '-G', 'DragNDrop'], cwd=build_dir, capture_output=False)
output_file = Path(build_dir) / f'KeePassXC-{version}.dmg'
output_file.rename(output_dir / f'KeePassXC-{version}-{platform_target}-unsigned.dmg')
unsigned_suffix = '-unsigned' if not sign else ''
output_file.rename(output_dir / f'KeePassXC-{version}-{platform_target}{unsigned_suffix}.dmg')
logger.info('All done! Please don\'t forget to sign the binaries before distribution.')
if sign:
logger.info('All done!')
else:
logger.info('All done! Please don\'t forget to sign the binaries before distribution.')
@staticmethod
def _download_tools_if_not_available(toolname, bin_dir, url, docker_args=None):
@ -888,162 +950,37 @@ class BuildSrc(Command):
tmp_comp.rename(output_file)
class AppSign(Command):
"""Sign binaries with code signing certificates on Windows and macOS."""
class Notarize(Command):
"""Notarize a signed macOS DMG app bundle."""
@classmethod
def setup_arg_parser(cls, parser: argparse.ArgumentParser):
parser.add_argument('file', help='Input file(s) to sign.', nargs='+')
parser.add_argument('-i', '--identity', help='Key or identity used for the signature (default: ask).')
parser.add_argument('-s', '--src-dir', help='Source directory (default: %(default)s).', default='.')
parser.add_argument('file', help='Input DMG file(s) to notarize.', nargs='+')
parser.add_argument('-p', '--keychain-profile', default='notarization-creds',
help='Read Apple credentials for notarization from a keychain (default: %(default)s).')
if sys.platform == 'darwin':
parser.add_argument('-n', '--notarize', help='Notarize signed file(s).', action='store_true')
parser.add_argument('-c', '--keychain-profile', default='notarization-creds',
help='Read Apple credentials for notarization from a keychain (default: %(default)s).')
def run(self, file, keychain_profile, **_):
if sys.platform != 'darwin':
raise Error('Unsupported platform.')
def run(self, file, identity, src_dir, **kwargs):
logger.warning('This tool is meant primarily for testing purposes. '
'For production use, add the --notarize flag to the build command.')
_macos_validate_keychain_profile(keychain_profile)
for i, f in enumerate(file):
f = Path(f)
if not f.exists():
raise Error('Input file does not exist: %s', f)
if f.suffix != '.dmg':
raise Error('Input file is not a DMG image: %s', f)
file[i] = f
if sys.platform == 'win32':
for f in file:
self.sign_windows(f, identity, Path(src_dir))
elif sys.platform == 'darwin':
Check.check_xcode_setup()
if kwargs['notarize']:
self._macos_validate_keychain_profile(kwargs['keychain_profile'])
identity = self._macos_get_codesigning_identity(identity)
for f in file:
out_file = self.sign_macos(f, identity, Path(src_dir))
if out_file and kwargs['notarize'] and out_file.suffix == '.dmg':
self.notarize_macos(out_file, kwargs['keychain_profile'])
else:
raise Error('Unsupported platform.')
self.notarize_macos(f, keychain_profile)
logger.info('All done.')
# noinspection PyMethodMayBeStatic
def sign_windows(self, file, identity, src_dir):
# Check for signtool
if not _cmd_exists('signtool.exe'):
raise Error('signtool was not found on the PATH.')
signtool_args = ['signtool', 'sign', '/fd', 'sha256', '/tr', 'http://timestamp.digicert.com', '/td', 'sha256']
if not identity:
logger.info('Using automatic selection of signing certificate.')
signtool_args += ['/a']
else:
logger.info('Using specified signing certificate: %s', identity)
signtool_args += ['/sha1', identity]
signtool_args += ['/d', file.name, str(file.resolve())]
_run(signtool_args, cwd=src_dir, capture_output=False)
# noinspection PyMethodMayBeStatic
def _macos_validate_keychain_profile(self, keychain_profile):
if _run(['security', 'find-generic-password', '-a',
f'com.apple.gke.notary.tool.saved-creds.{keychain_profile}'], cwd=None, check=False).returncode != 0:
raise Error(f'Keychain profile "%s" not found! Run\n'
f' {fmt.bold("xcrun notarytool store-credentials %s [...]" % keychain_profile)}\n'
f'to store your Apple notary service credentials in a keychain as "%s".',
keychain_profile, keychain_profile)
# noinspection PyMethodMayBeStatic
def _macos_get_codesigning_identity(self, user_choice=None):
result = _run(['security', 'find-identity', '-v', '-p', 'codesigning'], cwd=None, text=True)
identities = [i.strip() for i in result.stdout.strip().split('\n')[:-1]]
identities = [i.split(' ', 2)[1:] for i in identities]
if not identities:
raise Error('No codesigning identities found.')
if not user_choice and len(identities) == 1:
logger.info('Using codesigning identity %s.', identities[0][1])
return identities[0][0]
elif not user_choice:
return identities[_choice_prompt(
'The following code signing identities were found. Which one do you want to use?',
[' '.join(i) for i in identities])][0]
else:
for i in identities:
# Exact match of ID or substring match of description
if user_choice == i[0] or user_choice in i[1]:
return i[0]
raise Error('Invalid identity: %s', user_choice)
# noinspection PyMethodMayBeStatic
def sign_macos(self, file, identity, src_dir):
logger.info('Signing "%s"', file)
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp).absolute()
app_dir = tmp / 'app'
out_file = file.parent / file.name.replace('-unsigned', '')
if file.is_file() and file.suffix == '.dmg':
logger.debug('Unpacking disk image...')
mnt = tmp / 'mnt'
mnt.mkdir()
try:
_run(['hdiutil', 'attach', '-noautoopen', '-mountpoint', mnt.as_posix(), file.as_posix()], cwd=None)
shutil.copytree(mnt, app_dir, symlinks=True)
finally:
_run(['hdiutil', 'detach', mnt.as_posix()], cwd=None)
elif file.is_dir() and file.suffix == '.app':
logger.debug('Copying .app directory...')
shutil.copytree(file, app_dir, symlinks=True)
else:
logger.warning('Skipping non-app file "%s"', file)
return None
app_dir_app = list(app_dir.glob('*.app'))[0]
logger.debug('Signing libraries and frameworks...')
_run(['xcrun', 'codesign', f'--sign={identity}', '--force', '--options=runtime', '--deep',
app_dir_app.as_posix()], cwd=None)
# (Re-)Sign main executable with --entitlements
logger.debug('Signing main executable...')
_run(['xcrun', 'codesign', f'--sign={identity}', '--force', '--options=runtime',
'--entitlements', (src_dir / 'share/macosx/keepassxc.entitlements').as_posix(),
(app_dir_app / 'Contents/MacOS/KeePassXC').as_posix()], cwd=None)
tmp_out = out_file.with_suffix(f'.{"".join(random.choices(string.ascii_letters, k=8))}{file.suffix}')
try:
if file.suffix == '.dmg':
logger.debug('Repackaging disk image...')
dmg_size = sum(f.stat().st_size for f in app_dir.rglob('*'))
_run(['hdiutil', 'create', '-volname', 'KeePassXC', '-srcfolder', app_dir.as_posix(),
'-fs', 'HFS+', '-fsargs', '-c c=64,a=16,e=16', '-format', 'UDBZ',
'-size', f'{dmg_size}k', tmp_out.as_posix()],
cwd=None)
elif file.suffix == '.app':
shutil.copytree(app_dir, tmp_out, symlinks=True)
except Exception:
if tmp_out.is_file():
tmp_out.unlink()
elif tmp_out.is_dir():
shutil.rmtree(tmp_out, ignore_errors=True)
raise
finally:
# Replace original file if all went well
if tmp_out.exists():
if tmp_out.is_dir():
shutil.rmtree(file)
else:
file.unlink()
tmp_out.rename(out_file)
logger.info('File signed successfully and written to: "%s".', out_file)
return out_file
# noinspection PyMethodMayBeStatic
def notarize_macos(self, file, keychain_profile):
logger.info('Submitting "%s" for notarization...', file)
_run(['xcrun', 'notarytool', 'submit', f'--keychain-profile={keychain_profile}', '--wait',
file.as_posix()], cwd=None, capture_output=False)
@ -1271,9 +1208,10 @@ def main():
BuildSrc.setup_arg_parser(build_src_parser)
build_src_parser.set_defaults(_cmd=BuildSrc)
appsign_parser = subparsers.add_parser('appsign', help=AppSign.__doc__)
AppSign.setup_arg_parser(appsign_parser)
appsign_parser.set_defaults(_cmd=AppSign)
if sys.platform == 'darwin':
notarize_parser = subparsers.add_parser('notarize', help=Notarize.__doc__)
Notarize.setup_arg_parser(notarize_parser)
notarize_parser.set_defaults(_cmd=Notarize)
gpgsign_parser = subparsers.add_parser('gpgsign', help=GPGSign.__doc__)
GPGSign.setup_arg_parser(gpgsign_parser)