mirror of
https://github.com/keepassxreboot/keepassxc.git
synced 2025-11-26 09:36:33 -05:00
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:
parent
c09ba0113b
commit
4e59c1c579
8 changed files with 301 additions and 232 deletions
242
release-tool.py
242
release-tool.py
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue