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

@ -60,10 +60,17 @@ option(WITH_XC_KEESHARE "Sharing integration with KeeShare" OFF)
option(WITH_XC_UPDATECHECK "Include automatic update checks; disable for controlled distributions" ON) option(WITH_XC_UPDATECHECK "Include automatic update checks; disable for controlled distributions" ON)
if(UNIX AND NOT APPLE) if(UNIX AND NOT APPLE)
option(WITH_XC_FDOSECRETS "Implement freedesktop.org Secret Storage Spec server side API." OFF) option(WITH_XC_FDOSECRETS "Implement freedesktop.org Secret Storage Spec server side API." OFF)
set(WITH_XC_X11 ON CACHE BOOL "Enable building with X11 deps")
endif() endif()
option(WITH_XC_DOCS "Enable building of documentation" ON) option(WITH_XC_DOCS "Enable building of documentation" ON)
if(WIN32 OR APPLE)
set(WITH_XC_X11 ON CACHE BOOL "Enable building with X11 deps") set(WITH_XC_CODESIGN_IDENTITY "" CACHE STRING "Certificate to be used for signing binaries before packaging.")
if(WIN32)
set(WITH_XC_CODESIGN_TIMESTAMP_URL "http://timestamp.sectigo.com" CACHE STRING "Timestamp URL for Windows code signing.")
elseif(APPLE)
set(WITH_XC_NOTARY_KEYCHAIN_PROFILE "" CACHE STRING "Keychain profile name for stored Apple notarization credentials.")
endif()
endif()
if(APPLE) if(APPLE)
# Perform the platform checks before applying the stricter compiler flags. # Perform the platform checks before applying the stricter compiler flags.

View file

@ -36,7 +36,7 @@ find_library(
NAMES ${BOTAN_NAMES} NAMES ${BOTAN_NAMES}
PATH_SUFFIXES release/lib lib PATH_SUFFIXES release/lib lib
DOC "The Botan (release) library") DOC "The Botan (release) library")
if(MSVC) if(WIN32 AND NOT MINGW)
find_library( find_library(
BOTAN_LIBRARY_DEBUG BOTAN_LIBRARY_DEBUG
NAMES ${BOTAN_NAMES_DEBUG} NAMES ${BOTAN_NAMES_DEBUG}
@ -55,7 +55,7 @@ endif()
if(BOTAN_FOUND) if(BOTAN_FOUND)
set(BOTAN_INCLUDE_DIRS ${BOTAN_INCLUDE_DIR}) set(BOTAN_INCLUDE_DIRS ${BOTAN_INCLUDE_DIR})
if(MSVC) if(WIN32 AND NOT MINGW)
set(BOTAN_LIBRARIES optimized ${BOTAN_LIBRARY} debug ${BOTAN_LIBRARY_DEBUG}) set(BOTAN_LIBRARIES optimized ${BOTAN_LIBRARY} debug ${BOTAN_LIBRARY_DEBUG})
else() else()
set(BOTAN_LIBRARIES ${BOTAN_LIBRARY}) set(BOTAN_LIBRARIES ${BOTAN_LIBRARY})

View file

@ -15,7 +15,7 @@
find_path(QRENCODE_INCLUDE_DIR NAMES qrencode.h) find_path(QRENCODE_INCLUDE_DIR NAMES qrencode.h)
if(WIN32 AND MSVC) if(WIN32 AND NOT MINGW)
find_library(QRENCODE_LIBRARY_RELEASE qrencode) find_library(QRENCODE_LIBRARY_RELEASE qrencode)
find_library(QRENCODE_LIBRARY_DEBUG qrencoded) find_library(QRENCODE_LIBRARY_DEBUG qrencoded)
set(QRENCODE_LIBRARY optimized ${QRENCODE_LIBRARY_RELEASE} debug ${QRENCODE_LIBRARY_DEBUG}) set(QRENCODE_LIBRARY optimized ${QRENCODE_LIBRARY_RELEASE} debug ${QRENCODE_LIBRARY_DEBUG})

View file

@ -0,0 +1,102 @@
# Copyright (C) 2025 KeePassXC Team <team@keepassxc.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 2 or (at your option)
# version 3 of the License.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
# CPACK_PACKAGE_FILES is set only during POST_BUILD
if(NOT CPACK_PACKAGE_FILES) # PRE_BUILD: Sign binaries
set(PROGNAME "@PROGNAME@")
set(CODESIGN_IDENTITY "@WITH_XC_CODESIGN_IDENTITY@")
set(ENTITLEMENTS @MACOSX_BUNDLE_APPLE_ENTITLEMENTS@)
set(APP_DIR "${CPACK_TEMPORARY_INSTALL_DIRECTORY}/ALL_IN_ONE/${PROGNAME}.app")
if(NOT CODESIGN_IDENTITY)
message(FATAL_ERROR "No codesign identity specified.")
endif()
message(STATUS "Codesign identity used: ${CODESIGN_IDENTITY}")
message(STATUS "Signing ${PROGNAME}.app, this may take while...")
# Sign all binaries
execute_process(
COMMAND xcrun codesign --sign=${CODESIGN_IDENTITY} --force --options=runtime --deep ${APP_DIR}
RESULT_VARIABLE SIGN_RESULT
OUTPUT_VARIABLE SIGN_OUTPUT
ERROR_VARIABLE SIGN_ERROR
OUTPUT_STRIP_TRAILING_WHITESPACE
ERROR_STRIP_TRAILING_WHITESPACE
ECHO_OUTPUT_VARIABLE
)
if (NOT SIGN_RESULT EQUAL 0)
message(FATAL_ERROR "Signing binaries failed: ${SIGN_ERROR}")
endif()
# (Re-)Sign main executable with --entitlements
execute_process(
COMMAND xcrun codesign --sign=${CODESIGN_IDENTITY} --force --options=runtime --deep --entitlements=${ENTITLEMENTS} ${APP_DIR}
RESULT_VARIABLE SIGN_RESULT
OUTPUT_VARIABLE SIGN_OUTPUT
ERROR_VARIABLE SIGN_ERROR
OUTPUT_STRIP_TRAILING_WHITESPACE
ERROR_STRIP_TRAILING_WHITESPACE
ECHO_OUTPUT_VARIABLE
)
if (NOT SIGN_RESULT EQUAL 0)
message(FATAL_ERROR "Signing main binary failed: ${SIGN_ERROR}")
endif()
message(STATUS "${PROGNAME}.app signed successfully.")
else() # POST_BUILD: Notarize DMG
set(KEYCHAIN_PROFILE "@WITH_XC_NOTARY_KEYCHAIN_PROFILE@")
file(GLOB_RECURSE DMG_FILE "${CPACK_PACKAGE_DIRECTORY}/${CPACK_PACKAGE_FILE_NAME}.dmg")
if(NOT KEYCHAIN_PROFILE)
message(FATAL_ERROR "No notarization credentials keychain profile specified.")
endif()
# Submit for notarization
message(STATUS "Submitting DMG bundle for notarization, this may take while...")
execute_process(
COMMAND xcrun notarytool submit --keychain-profile=${KEYCHAIN_PROFILE} --wait ${DMG_FILE}
RESULT_VARIABLE NOTARIZE_RESULT
OUTPUT_VARIABLE NOTARIZE_OUTPUT
ERROR_VARIABLE NOTARIZE_ERROR
OUTPUT_STRIP_TRAILING_WHITESPACE
ERROR_STRIP_TRAILING_WHITESPACE
ECHO_OUTPUT_VARIABLE
)
if (NOT NOTARIZE_RESULT EQUAL 0)
message(FATAL_ERROR "Notarization failed: ${NOTARIZE_ERROR}")
endif()
message(STATUS "DMG bundle notarized successfully.")
# Staple tickets
message(STATUS "Stapling notarization ticket...")
execute_process(
COMMAND xcrun stapler staple ${DMG_FILE} && xcrun stapler validate ${DMG_FILE}
RESULT_VARIABLE STAPLE_RESULT
OUTPUT_VARIABLE STAPLE_OUTPUT
ERROR_VARIABLE STAPLE_ERROR
OUTPUT_STRIP_TRAILING_WHITESPACE
ERROR_STRIP_TRAILING_WHITESPACE
ECHO_OUTPUT_VARIABLE
)
if (NOT STAPLE_RESULT EQUAL 0)
message(FATAL_ERROR "Stapling failed: ${STAPLE_ERROR}")
endif()
message(STATUS "DMG bundle notarization ticket stapled successfully.")
endif()

View file

@ -0,0 +1,79 @@
# Copyright (C) 2025 KeePassXC Team <team@keepassxc.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 2 or (at your option)
# version 3 of the License.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
set(INSTALL_DIR ${CPACK_TEMPORARY_INSTALL_DIRECTORY})
set(CODESIGN_IDENTITY @WITH_XC_CODESIGN_IDENTITY@)
set(TIMESTAMP_URL @WITH_XC_CODESIGN_TIMESTAMP_URL@)
if(CPACK_PACKAGE_FILES)
# This variable is set only during POST_BUILD, reset SIGN_FILES first
set(SIGN_FILES "")
foreach(PACKAGE_FILE ${CPACK_PACKAGE_FILES})
# Check each package file to see if it can be signed
if(PACKAGE_FILE MATCHES "\\.msix?$" OR PACKAGE_FILE MATCHES "\\.exe$")
message(STATUS "Adding ${PACKAGE_FILE} for signature")
list(APPEND SIGN_FILES "${PACKAGE_FILE}")
endif()
endforeach()
else()
# Setup portable zip file if building one
if(INSTALL_DIR MATCHES "/ZIP/")
file(TOUCH "${INSTALL_DIR}/.portable")
message(STATUS "Injected portable marker into ZIP file.")
endif()
# Find all dll and exe files in the install directory
file(GLOB_RECURSE SIGN_FILES
RELATIVE "${INSTALL_DIR}"
"${INSTALL_DIR}/*.dll"
"${INSTALL_DIR}/*.exe"
)
endif()
# Sign relevant binaries if requested
if(CODESIGN_IDENTITY AND SIGN_FILES)
# Find signtool in PATH or error out
find_program(SIGNTOOL signtool.exe QUIET)
if(NOT SIGNTOOL)
message(FATAL_ERROR "signtool.exe not found in PATH, correct or unset WITH_XC_CODESIGN_IDENTITY")
endif()
# Check that a certificate thumbprint was provided or error out
if(CODESIGN_IDENTITY STREQUAL "auto")
message(STATUS "Signing using best available certificate.")
set(CERT_OPTS /a)
else ()
message(STATUS "Signing using certificate with fingerprint ${CODESIGN_IDENTITY}.")
set(CERT_OPTS /sha1 ${CODESIGN_IDENTITY})
endif()
message(STATUS "Signing binary files, this may take a while...")
# Use cmd /c to enable pop-up for pin entry if needed
execute_process(
COMMAND cmd /c ${SIGNTOOL} sign /fd SHA256 ${CERT_OPTS} /tr ${TIMESTAMP_URL} /td SHA256 /d ${CPACK_PACKAGE_FILE_NAME} ${SIGN_FILES}
WORKING_DIRECTORY "${INSTALL_DIR}"
RESULT_VARIABLE SIGN_RESULT
OUTPUT_VARIABLE SIGN_OUTPUT
ERROR_VARIABLE SIGN_ERROR
OUTPUT_STRIP_TRAILING_WHITESPACE
ERROR_STRIP_TRAILING_WHITESPACE
ECHO_OUTPUT_VARIABLE
)
if(NOT SIGN_RESULT EQUAL 0)
message(FATAL_ERROR "Signing binary files failed: ${SIGN_ERROR}")
endif()
message(STATUS "Binary files signed successfully.")
endif()

View file

@ -1,71 +0,0 @@
# Copyright (C) 2025 KeePassXC Team <team@keepassxc.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 2 or (at your option)
# version 3 of the License.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
set(_installdir ${CPACK_TEMPORARY_INSTALL_DIRECTORY})
set(_sign @WITH_XC_SIGNINSTALL@)
set(_cert_thumbprint @WITH_XC_SIGNINSTALL_CERT@)
set(_timestamp_url @WITH_XC_SIGNINSTALL_TIMESTAMP_URL@)
# Setup portable zip file if building one
if(_installdir MATCHES "/ZIP/")
file(TOUCH "${_installdir}/.portable")
message(STATUS "Injected portable zip file.")
endif()
# Find all dll and exe files in the install directory
file(GLOB_RECURSE _sign_files
RELATIVE "${_installdir}"
"${_installdir}/*.dll"
"${_installdir}/*.exe"
)
# Sign relevant binaries if requested
if(_sign AND _sign_files)
# Find signtool in PATH or error out
find_program(_signtool signtool.exe QUIET)
if(NOT _signtool)
message(FATAL_ERROR "signtool.exe not found in PATH, correct or unset WITH_XC_SIGNINSTALL")
endif()
# Set a default timestamp URL if none was provided
if (NOT _timestamp_url)
set(_timestamp_url "http://timestamp.sectigo.com")
endif()
# Check that a certificate thumbprint was provided or error out
if (NOT _cert_thumbprint)
message(STATUS "Signing using best available certificate.")
set(_certopt /a)
else()
message(STATUS "Signing using certificate with thumbprint ${_cert_thumbprint}.")
set(_certopt /sha1 ${_cert_thumbprint})
endif()
message(STATUS "Signing binary files with signtool, this may take a while...")
# Use cmd /c to enable pop-up for pin entry if needed
execute_process(
COMMAND cmd /c ${_signtool} sign /fd SHA256 ${_certopt} /tr ${_timestamp_url} /td SHA256 ${_sign_files}
WORKING_DIRECTORY "${_installdir}"
RESULT_VARIABLE sign_result
OUTPUT_VARIABLE sign_output
ERROR_VARIABLE sign_error
OUTPUT_STRIP_TRAILING_WHITESPACE
ERROR_STRIP_TRAILING_WHITESPACE
ECHO_OUTPUT_VARIABLE
)
if (NOT sign_result EQUAL 0)
message(FATAL_ERROR "signtool failed: ${sign_error}")
endif()
endif()

View file

@ -342,6 +342,48 @@ def _capture_vs_env(arch='amd64'):
return env 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 # CLI Commands
########################################################################################### ###########################################################################################
@ -585,6 +627,13 @@ class Build(Command):
help='macOS deployment target version (default: %(default)s).') help='macOS deployment target version (default: %(default)s).')
parser.add_argument('-p', '--platform-target', default=platform.uname().machine, parser.add_argument('-p', '--platform-target', default=platform.uname().machine,
help='Build target platform (default: %(default)s).', choices=['x86_64', 'arm64']) 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': elif sys.platform == 'linux':
parser.add_argument('-d', '--docker-image', help='Run build in Docker image (overrides --use-system-deps).') 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).', 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).', parser.add_argument('-p', '--platform-target', help='Build target platform (default: %(default)s).',
choices=['amd64', 'arm64'], default='amd64') choices=['amd64', 'arm64'], default='amd64')
parser.add_argument('--sign', help='Sign binaries prior to packaging.', action='store_true') 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.add_argument('--sign-identity', help='SHA1 fingerprint of the signing certificate.')
parser.set_defaults(cmake_generator='Ninja', no_source_tarball=True) 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, parser.add_argument('-c', '--cmake-opts', nargs=argparse.REMAINDER,
help='Additional CMake options (no other arguments can be specified after this).') help='Additional CMake options (no other arguments can be specified after this).')
@ -674,15 +725,15 @@ class Build(Command):
# noinspection PyMethodMayBeStatic # noinspection PyMethodMayBeStatic
def build_windows(self, version, src_dir, output_dir, *, parallelism, cmake_opts, platform_target, 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 # Check for required tools
if not _cmd_exists('candle.exe') or not _cmd_exists('light.exe') or not _cmd_exists('heat.exe'): 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).') raise Error('WiX Toolset not found on the PATH (candle.exe, light.exe, heat.exe).')
# Setup build signing if requested # Setup build signing if requested
if sign: if sign:
cmake_opts.append('-DWITH_XC_SIGNINSTALL=ON') cmake_opts.append(f'-DWITH_XC_CODESIGN_IDENTITY={sign_identity}')
cmake_opts.append(f'-DWITH_XC_SIGNINSTALL_CERT={sign_cert}') cmake_opts.append(f'-WITH_XC_CODESIGN_TIMESTAMP_URL={sign_timestamp_url}')
# Use vcpkg for dependency deployment # Use vcpkg for dependency deployment
cmake_opts.append('-DX_VCPKG_APPLOCAL_DEPS_INSTALL=ON') cmake_opts.append('-DX_VCPKG_APPLOCAL_DEPS_INSTALL=ON')
@ -716,13 +767,20 @@ class Build(Command):
# noinspection PyMethodMayBeStatic # noinspection PyMethodMayBeStatic
def build_macos(self, version, src_dir, output_dir, *, use_system_deps, parallelism, cmake_opts, 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: if not use_system_deps:
cmake_opts.append(f'-DVCPKG_TARGET_TRIPLET={platform_target.replace("86_", "")}-osx-dynamic-release') 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_DEPLOYMENT_TARGET={macos_target}')
cmake_opts.append(f'-DCMAKE_OSX_ARCHITECTURES={platform_target}') cmake_opts.append(f'-DCMAKE_OSX_ARCHITECTURES={platform_target}')
with tempfile.TemporaryDirectory() as build_dir: 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...') logger.info('Configuring build...')
_run(['cmake', *cmake_opts, str(src_dir)], cwd=build_dir, capture_output=False) _run(['cmake', *cmake_opts, str(src_dir)], cwd=build_dir, capture_output=False)
@ -736,8 +794,12 @@ class Build(Command):
_run(['cpack', '-G', 'DragNDrop'], cwd=build_dir, capture_output=False) _run(['cpack', '-G', 'DragNDrop'], cwd=build_dir, capture_output=False)
output_file = Path(build_dir) / f'KeePassXC-{version}.dmg' 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')
if sign:
logger.info('All done!')
else:
logger.info('All done! Please don\'t forget to sign the binaries before distribution.') logger.info('All done! Please don\'t forget to sign the binaries before distribution.')
@staticmethod @staticmethod
@ -888,162 +950,37 @@ class BuildSrc(Command):
tmp_comp.rename(output_file) tmp_comp.rename(output_file)
class AppSign(Command): class Notarize(Command):
"""Sign binaries with code signing certificates on Windows and macOS.""" """Notarize a signed macOS DMG app bundle."""
@classmethod @classmethod
def setup_arg_parser(cls, parser: argparse.ArgumentParser): def setup_arg_parser(cls, parser: argparse.ArgumentParser):
parser.add_argument('file', help='Input file(s) to sign.', nargs='+') parser.add_argument('file', help='Input DMG file(s) to notarize.', nargs='+')
parser.add_argument('-i', '--identity', help='Key or identity used for the signature (default: ask).') parser.add_argument('-p', '--keychain-profile', default='notarization-creds',
parser.add_argument('-s', '--src-dir', help='Source directory (default: %(default)s).', default='.')
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).') help='Read Apple credentials for notarization from a keychain (default: %(default)s).')
def run(self, file, identity, src_dir, **kwargs): def run(self, file, keychain_profile, **_):
if sys.platform != 'darwin':
raise Error('Unsupported platform.')
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): for i, f in enumerate(file):
f = Path(f) f = Path(f)
if not f.exists(): if not f.exists():
raise Error('Input file does not exist: %s', f) 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 file[i] = f
self.notarize_macos(f, keychain_profile)
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.')
logger.info('All done.') 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 # noinspection PyMethodMayBeStatic
def notarize_macos(self, file, keychain_profile): def notarize_macos(self, file, keychain_profile):
logger.info('Submitting "%s" for notarization...', file) logger.info('Submitting "%s" for notarization...', file)
_run(['xcrun', 'notarytool', 'submit', f'--keychain-profile={keychain_profile}', '--wait', _run(['xcrun', 'notarytool', 'submit', f'--keychain-profile={keychain_profile}', '--wait',
file.as_posix()], cwd=None, capture_output=False) file.as_posix()], cwd=None, capture_output=False)
@ -1271,9 +1208,10 @@ def main():
BuildSrc.setup_arg_parser(build_src_parser) BuildSrc.setup_arg_parser(build_src_parser)
build_src_parser.set_defaults(_cmd=BuildSrc) build_src_parser.set_defaults(_cmd=BuildSrc)
appsign_parser = subparsers.add_parser('appsign', help=AppSign.__doc__) if sys.platform == 'darwin':
AppSign.setup_arg_parser(appsign_parser) notarize_parser = subparsers.add_parser('notarize', help=Notarize.__doc__)
appsign_parser.set_defaults(_cmd=AppSign) Notarize.setup_arg_parser(notarize_parser)
notarize_parser.set_defaults(_cmd=Notarize)
gpgsign_parser = subparsers.add_parser('gpgsign', help=GPGSign.__doc__) gpgsign_parser = subparsers.add_parser('gpgsign', help=GPGSign.__doc__)
GPGSign.setup_arg_parser(gpgsign_parser) GPGSign.setup_arg_parser(gpgsign_parser)

View file

@ -426,7 +426,7 @@ target_link_libraries(${PROGNAME} keepassxc_gui)
set_target_properties(${PROGNAME} PROPERTIES ENABLE_EXPORTS ON) set_target_properties(${PROGNAME} PROPERTIES ENABLE_EXPORTS ON)
if(WIN32) if(WIN32)
set_target_properties(${PROGNAME} PROPERTIES WIN32 ON) set_target_properties(${PROGNAME} PROPERTIES WIN32_EXECUTABLE ON)
include(GenerateProductVersion) include(GenerateProductVersion)
generate_product_version( generate_product_version(
WIN32_ResourceFiles WIN32_ResourceFiles
@ -442,6 +442,7 @@ if(WIN32)
elseif(APPLE AND WITH_APP_BUNDLE) elseif(APPLE AND WITH_APP_BUNDLE)
set(MACOSX_BUNDLE_IDENTIFIER org.keepassxc.keepassxc) set(MACOSX_BUNDLE_IDENTIFIER org.keepassxc.keepassxc)
set(MACOSX_BUNDLE_ICON_NAME keepassxc) set(MACOSX_BUNDLE_ICON_NAME keepassxc)
set(MACOSX_BUNDLE_APPLE_ENTITLEMENTS "${CMAKE_SOURCE_DIR}/share/macosx/keepassxc.entitlements")
configure_file("${CMAKE_SOURCE_DIR}/share/macosx/Info.plist.cmake" ${CMAKE_CURRENT_BINARY_DIR}/Info.plist) configure_file("${CMAKE_SOURCE_DIR}/share/macosx/Info.plist.cmake" ${CMAKE_CURRENT_BINARY_DIR}/Info.plist)
install(FILES "${CMAKE_SOURCE_DIR}/share/macosx/embedded.provisionprofile" DESTINATION ${BUNDLE_INSTALL_DIR}) install(FILES "${CMAKE_SOURCE_DIR}/share/macosx/embedded.provisionprofile" DESTINATION ${BUNDLE_INSTALL_DIR})
set(MACOSX_BUNDLE_RESOURCE_FILES set(MACOSX_BUNDLE_RESOURCE_FILES
@ -451,7 +452,7 @@ elseif(APPLE AND WITH_APP_BUNDLE)
set_target_properties(${PROGNAME} PROPERTIES set_target_properties(${PROGNAME} PROPERTIES
MACOSX_BUNDLE ON MACOSX_BUNDLE ON
MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_BINARY_DIR}/Info.plist" MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_BINARY_DIR}/Info.plist"
CPACK_BUNDLE_APPLE_ENTITLEMENTS "${CMAKE_SOURCE_DIR}/share/macosx/keepassxc.entitlements" CPACK_BUNDLE_APPLE_ENTITLEMENTS "${MACOSX_BUNDLE_APPLE_ENTITLEMENTS}"
RESOURCE "${MACOSX_BUNDLE_RESOURCE_FILES}" RESOURCE "${MACOSX_BUNDLE_RESOURCE_FILES}"
) )
target_sources(${PROGNAME} PUBLIC ${MACOSX_BUNDLE_RESOURCE_FILES}) target_sources(${PROGNAME} PUBLIC ${MACOSX_BUNDLE_RESOURCE_FILES})
@ -461,6 +462,18 @@ elseif(APPLE AND WITH_APP_BUNDLE)
DESTINATION "${DATA_INSTALL_DIR}") DESTINATION "${DATA_INSTALL_DIR}")
endif() endif()
# Sign binaries
if(WITH_XC_CODESIGN_IDENTITY)
configure_file("${CMAKE_SOURCE_DIR}/cmake/MacOSCodesign.cmake.in" "${CMAKE_BINARY_DIR}/MacOSCodesign.cmake" @ONLY)
set(CPACK_PRE_BUILD_SCRIPTS "${CMAKE_BINARY_DIR}/MacOSCodesign.cmake")
if(WITH_XC_NOTARY_KEYCHAIN_PROFILE)
configure_file("${CMAKE_SOURCE_DIR}/cmake/MacOSCodesign.cmake.in" "${CMAKE_BINARY_DIR}/MacOSCodesign.cmake" @ONLY)
set(CPACK_POST_BUILD_SCRIPTS "${CMAKE_BINARY_DIR}/MacOSCodesign.cmake")
else()
message(INFO "Do not forget to notarize DMG package before distribution!")
endif()
endif()
set(CPACK_GENERATOR "DragNDrop") set(CPACK_GENERATOR "DragNDrop")
set(CPACK_DMG_FORMAT "UDBZ") set(CPACK_DMG_FORMAT "UDBZ")
set(CPACK_DMG_DS_STORE "${CMAKE_SOURCE_DIR}/share/macosx/DS_Store.in") set(CPACK_DMG_DS_STORE "${CMAKE_SOURCE_DIR}/share/macosx/DS_Store.in")
@ -492,8 +505,9 @@ if(WIN32)
"${CMAKE_CURRENT_BINARY_DIR}/INSTALLER_LICENSE.txt") "${CMAKE_CURRENT_BINARY_DIR}/INSTALLER_LICENSE.txt")
# Prepare post-install script and set to run prior to building cpack installers # Prepare post-install script and set to run prior to building cpack installers
configure_file("${CMAKE_SOURCE_DIR}/cmake/WindowsPostInstall.cmake.in" "${CMAKE_BINARY_DIR}/WindowsPostInstall.cmake" @ONLY) configure_file("${CMAKE_SOURCE_DIR}/cmake/WindowsCodesign.cmake.in" "${CMAKE_BINARY_DIR}/WindowsCodesign.cmake" @ONLY)
set(CPACK_PRE_BUILD_SCRIPTS "${CMAKE_BINARY_DIR}/WindowsPostInstall.cmake") set(CPACK_PRE_BUILD_SCRIPTS "${CMAKE_BINARY_DIR}/WindowsCodesign.cmake")
set(CPACK_POST_BUILD_SCRIPTS "${CMAKE_BINARY_DIR}/WindowsCodesign.cmake")
string(REGEX REPLACE "-.*$" "" KEEPASSXC_VERSION_CLEAN ${KEEPASSXC_VERSION}) string(REGEX REPLACE "-.*$" "" KEEPASSXC_VERSION_CLEAN ${KEEPASSXC_VERSION})