Search code examples
cmakensiscode-signingcpack

How to sign Windows binaries and NSIS installers when building with cmake + cpack


I am building an NSIS installer that includes a shared library and a set of tools that use that library. I need to sign everything so users can install it without getting scary warnings from Windows.

In all my searches on this question, and variations thereof, I could only find bits of the answer and even those were not complete. E.g. "You have to use a custom post build command" without details. Also "Because the NSIS builds the uninstall executable during compilation signing the installer is complicated" which points at an NSIS url explaining the procedure when using NSIS directly. At the top of that page it says version 3.08 has a new uninstfinalize command that obsoletes the procedure described here. But there is no indication how to use it.


Solution

  • I posted the question so I can share with others who have asked this question what I learned getting signing working. I don't maintain a blog so SO seems like a good way to share with a relevant audience and give a little payback for the many things I have learned from the web. Here goes ...

    Signing on Windows

    If you are new to signing on Windows, you need to know about related commands. The actual signing is done with signtool which is found in Windows SDKs. This can find the certificate in a Windows' certificate store or you can provide it in a PFX (.p12) file via the command line.

    Windows has three commands of interest for managing certificate stores: certmgr, certlm and certutil. The first two are interactive, the third is a command line utility. certmgr is for managing the Current User store. certlm is for managing the Local Machine store. certutil operates on the Local Machine store by default but operates on the Current User store when the -user option is specified.

    Note: How to obtain a certificate is out of the scope of this answer.

    Signing with CMake

    To sign executables and dlls built by CMake you need to add a custom post build command for each target to be signed. Here is a macro I use to add one to a target:

    macro (set_code_sign target)
      if (WIN32 AND WIN_CODE_SIGN_IDENTITY)
        find_package(signtool REQUIRED)
    
        if (signtool_EXECUTABLE)
          configure_sign_params()
          add_custom_command( TARGET ${target}
           POST_BUILD
           COMMAND ${signtool_EXECUTABLE} sign ${SIGN_PARAMS} $<TARGET_FILE:${target}>
           VERBATIM
          )
        endif()
      endif()
    endmacro (set_code_sign)
    

    Here is a typical use of the above macro:

    add_executable( mycheck
        ...
    )
    set_code_sign(mycheck)
    
    
    

    In order to locate signtool I created Findsigntool.cmake:

    #[============================================================================
    # Copyright 2022, Khronos Group, Inc.
    # SPDX-License-Identifier: Apache-2.0
    #============================================================================]
    
    #  Functions to convert unix-style paths into paths useable by cmake on windows.
    #[=======================================================================[.rst:
    Findsigntool
    -------
    
    Finds the signtool executable used for codesigning on Windows.
    
    Note that signtool does not offer a way to make it print its version
    so version selection and reporting is not possible.
    
    Result Variables
    ^^^^^^^^^^^^^^^^
    
    This will define the following variables:
    
    ``signtool_FOUND``
      True if the system has the signtool executable.
    ``signtool_EXECUTABLE``
      The signtool command executable.
    
    #]=======================================================================]
    
    if (WIN32 AND CMAKE_HOST_SYSTEM_NAME MATCHES "CYGWIN.*")
      find_program(CYGPATH
          NAMES cygpath
          HINTS [HKEY_LOCAL_MACHINE\\Software\\Cygwin\\setup;rootdir]/bin
          PATHS C:/cygwin64/bin
                C:/cygwin/bin
      )
    endif ()
    
    function(convert_cygwin_path _pathvar)
      if (WIN32 AND CYGPATH)
        execute_process(
            COMMAND         "${CYGPATH}" -m "${${_pathvar}}"
            OUTPUT_VARIABLE ${_pathvar}
            OUTPUT_STRIP_TRAILING_WHITESPACE
        )
        set(${_pathvar} "${${_pathvar}}" PARENT_SCOPE)
      endif ()
    endfunction()
    
    function(convert_windows_path _pathvar)
      if (CYGPATH)
        execute_process(
            COMMAND         "${CYGPATH}" "${${_pathvar}}"
            OUTPUT_VARIABLE ${_pathvar}
            OUTPUT_STRIP_TRAILING_WHITESPACE
        )
        set(${_pathvar} "${${_pathvar}}" PARENT_SCOPE)
      endif ()
    endfunction()
    
    # Make a list of Windows Kit versions with newer versions first.
    #
    # _winver   string          Windows version whose signtool to find.
    # _versions variable name   Variable in which to return the list of versions.
    #
    function(find_kits _winver _kit_versions)
      set(${_kit_versions})
      set(_kit_root "KitsRoot${_winver}")
      set(regkey "HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Windows Kits\\Installed Roots")
      set(regval ${_kit_root})
      if(CMAKE_HOST_SYSTEM_NAME MATCHES "Windows")
        # Note: must be a cache operation in order to read from the registry.
        get_filename_component(_kits_path "[${regkey};${regval}]"
            ABSOLUTE CACHE
        )
      elseif(CMAKE_HOST_SYSTEM_NAME MATCHES "CYGWIN.*")
        # On Cygwin, CMake's built-in registry query won't work.
        # Use Cygwin utility "regtool" instead.
        execute_process(COMMAND regtool get "\\${regkey}\\${regval}"
          OUTPUT_VARIABLE _kits_path}
          ERROR_QUIET
          OUTPUT_STRIP_TRAILING_WHITESPACE
        )
        if (_kits_path)
          convert_windows_path(_kits_path)
        endif ()
      endif()
      if (_kits_path)
          file(GLOB ${_kit_versions} "${_kits_path}/bin/${_winver}.*")
          # Reverse list, so newer versions (higher-numbered) appear first.
          list(REVERSE ${_kit_versions})
      endif ()
      unset(_kits_path CACHE)
      set(${_kit_versions} ${${_kit_versions}} PARENT_SCOPE)
    endfunction()
    
    if (WIN32 AND NOT signtool_EXECUTABLE)
      if(${CMAKE_HOST_SYSTEM_PROCESSOR} STREQUAL "AMD64")
        set(arch "x64")
      else()
        set(arch ${CMAKE_HOST_SYSTEM_PROCESSOR})
      endif()
    
      # Look for latest signtool
      foreach(winver 11 10)
        find_kits(${winver} kit_versions)
        if (kit_versions)
          find_program(signtool_EXECUTABLE
              NAMES           signtool
              PATHS           ${kit_versions}
              PATH_SUFFIXES   ${arch}
                              bin/${arch}
                              bin
              NO_DEFAULT_PATH
          )
          if (signtool_EXECUTABLE)
            break()
          endif()
        endif()
      endforeach()
    
      if (signtool_EXECUTABLE)
        mark_as_advanced (signtool_EXECUTABLE)
      endif ()
    
      # handle the QUIETLY and REQUIRED arguments and set *_FOUND to TRUE
      # if all listed variables are found or TRUE
      include (FindPackageHandleStandardArgs)
    
      find_package_handle_standard_args (
        signtool
        REQUIRED_VARS
          signtool_EXECUTABLE
        FAIL_MESSAGE
          "Could NOT find signtool. Will be unable to sign Windows binaries."
      )
    endif()
    

    Put this in your project's cmake module path.

    And here is the function to configure the signing parameters. I use a variable because we need to repeat the same parameters in the commands for signing the installer:

    function(configure_sign_params)
      if (NOT SIGN_PARAMS)
        # Default to looking for cert. in user's store but let user tell us
        # to look in Local Computer store. User store is preferred because importing
        # the cert. does not need admin elevation.
        if (WIN_CS_CERT_SEARCH_MACHINE_STORE)
          set(store "/sm")
        endif()
        set(SIGN_PARAMS ${store} /fd sha256 /n "${WIN_CODE_SIGN_IDENTITY}"
            /tr http://ts.ssl.com /td sha256
            /d "My Software" /du https://github.com/Me/My-Software
            PARENT_SCOPE)
      endif()
    endfunction()
    

    If you imported your certificate to the Local Machine store, then you need the /sm parameter which this code will set if the `WIN_CS_CERT_SEARCH_MACHINE_STORE option is toggled on during cmake configuration.

    [I added the option to use the Local Machine store due to issues I had importing our certificate to the Current User store via certutil in a CI environment.]

    If you have your certificate in a PFX file then replace /n "code sign identity" with -f your_cert.p12 -p <your private key password>.

    Here is the extract from the project's top-level CMakeLists.txt where the signing related options are set:

    if (WIN32)
        set( WIN_CODE_SIGN_IDENTITY "" CACHE STRING "Subject Name of Windows code signing certificate. Displayed in 'Issued To' column of cert{lm,mgr}.")
        CMAKE_DEPENDENT_OPTION( WIN_CS_CERT_SEARCH_MACHINE_STORE
         "When set, machine store will be searched for signing certificate instead of user store."
         OFF
         WIN_CODE_SIGN_IDENTITY
         OFF
        )
    endif()
    

    Signing an NSIS installer via CPack

    NSIS, and likely other installers, build the uninstall executable on the fly before including it in the installer. This too must be signed. Doing so was previously difficult but a new uninstfinalize command was added to NSIS 3.08 that makes it straightforward. The existing instfinalize command is used for signing the installer. These commands are not supported by standard CMake so you must make a custom NSIS script as described in NSISAdvancedTips.

    Copy the file NSIS.template.in from your CMake installation's module path into your project's module path. Add the following lines

    ;--------------------------------
    ;Signing
    
      !finalize '@CPACK_NSIS_FINALIZE_CMD@'
      !uninstfinalize '@CPACK_NSIS_FINALIZE_CMD@'
    

    I don't think the location in the file is particularly important. I put them between the Include Modern UI and General sections.

    When cpack generates the installer script it replaces @CPACK_NSIS_FINALIZE_CMD@ with the value of the corresponding CMake variable, if any. Here is a function to define the variable:

    function(set_nsis_installer_codesign_cmd)
      if (WIN32 AND WIN_CODE_SIGN_IDENTITY)
        # To make calls to the set_code_sign macro and this order independent ...
        find_package(signtool REQUIRED)
        if (signtool_EXECUTABLE)
          configure_sign_params()
          # CPACK_NSIS_FINALIZE_CMD is a variable whose value is to be substituted
          # into the !finalize and !uninstfinalize commands in
          # cmake/modules/NSIS.template.in. This variable is ours. It is not a
          # standard CPACK variable. The name MUST start with CPACK otherwise
          # it will not be defined when cpack runs its configure_file step.
          foreach(param IN LISTS SIGN_PARAMS)
            # Quote the parameters because at least one of them,
            # WIN_CODE_SIGN_IDENTITY, has spaces. It is easier to quote
            # all of them than determine which have spaces.
            #
            # Insane escaping is needed due to the 2-step process used to
            # configure the final output. First cpack creates CPackConfig.cmake
            # in which the value set here appears, inside quotes, as the
            # argument to a cmake `set` command. That variable's value
            # is then substituted into the output.
            string(APPEND NSIS_SIGN_PARAMS "\\\"${param}\\\" ")
          endforeach()
    
          # Note 1: cpack/NSIS does not show any output when running signtool,
          # whether it succeeds or fails.
          #
          # Note 2: Do not move the %1 to NSIS.template.in. We need an empty
          # command there when we aren't signing. %1 is replaced by the name
          # of the installer or uninstaller during NSIS compilation.
          set(CPACK_NSIS_FINALIZE_CMD "\\\"${signtool_EXECUTABLE}\\\" sign ${NSIS_SIGN_PARAMS} %1"
            PARENT_SCOPE
          )
          unset(NSIS_SIGN_PARAMS)
        endif()
      endif()
    endfunction()
    

    Pay attention to the comments in the above function.

    Finally we need to call this function. Here is what I do in the section of my project's CMakeLists.txt where I set all the standard CPACK_* variables of interest:

        if (WIN_CODE_SIGN_IDENTITY)
            set_nsis_installer_codesign_cmd()
        else()
            # We're not signing the package so provide a checksum file.
            set(CPACK_PACKAGE_CHECKSUM SHA1)
        endif()
    

    There you have it. It wasn't so difficult in the end.