diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d6f0f0913..22f3ff7e2 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -94,6 +94,10 @@ jobs: activate-environment: constructor-dev environment-file: dev/environment.yml python-version: ${{ matrix.python-version }} + - name: Install AzureSignTool + if: matrix.os == 'windows' + run: dotnet.exe tool install --global AzureSignTool + shell: pwsh - name: Supply extra dependencies and install constructor run: | files=(--file "tests/requirements.txt") @@ -129,6 +133,11 @@ jobs: flags: unit - name: Run examples env: + AZURE_SIGNTOOL_KEY_VAULT_CERTIFICATE: ${{ secrets.AZURE_SIGNTOOL_KEY_VAULT_CERTIFICATE }} + AZURE_SIGNTOOL_KEY_VAULT_CLIENT_ID: ${{ secrets.AZURE_SIGNTOOL_KEY_VAULT_CLIENT_ID }} + AZURE_SIGNTOOL_KEY_VAULT_SECRET: ${{ secrets.AZURE_SIGNTOOL_KEY_VAULT_SECRET }} + AZURE_SIGNTOOL_KEY_VAULT_TENANT_ID: ${{ secrets.AZURE_SIGNTOOL_KEY_VAULT_TENANT_ID }} + AZURE_SIGNTOOL_KEY_VAULT_URL: ${{ secrets.AZURE_SIGNTOOL_KEY_VAULT_URL }} CONSTRUCTOR_EXAMPLES_KEEP_ARTIFACTS: "${{ runner.temp }}/examples_artifacts" CONSTRUCTOR_SIGNTOOL_PATH: "C:/Program Files (x86)/Windows Kits/10/bin/10.0.17763.0/x86/signtool.exe" run: | diff --git a/CONSTRUCT.md b/CONSTRUCT.md index 6497acd5d..9e02ff8e2 100644 --- a/CONSTRUCT.md +++ b/CONSTRUCT.md @@ -332,18 +332,25 @@ to sign `conda.exe`. For this, you need an "Application certificate" (different "Installer certificate" mentioned above). Common values for this option follow the format `Developer ID Application: Name of the owner (XXXXXX)`. -### `signing_certificate` +### `windows_signing_tool` _required:_ no
_type:_ string
-On Windows only, set this key to the path of a PFX certificate to be used with `signtool`. -Additional environment variables can be used to configure this step, namely: +The tool used to sign Windows installers. Must be one of: azuresigntool, signtool. +Some tools require `signing_certificate` to be set. +Defaults to `signtool` if `signing_certificate` is set. +Additional environment variables may need to be used to configure signing. +See the documentation for details: +https://conda.github.io/constructor/howto/#signing-exe-installers + +### `signing_certificate` + +_required:_ no
+_type:_ string
-- `CONSTRUCTOR_PFX_CERTIFICATE_PASSWORD` (password to unlock the certificate, if needed) -- `CONSTRUCTOR_SIGNTOOL_PATH` (absolute path to `signtool.exe`, in case is not in `PATH`) -- `CONSTRUCTOR_SIGNTOOL_TIMESTAMP_SERVER_URL` (custom RFC 3161 timestamping server, default is -http://timestamp.sectigo.com) +On Windows only, set this key to the path of the certificate file to be used +with the `windows_signing_tool`. ### `attempt_hardlinks` diff --git a/constructor/construct.py b/constructor/construct.py index 6a1d6fb5a..2fe4c8978 100644 --- a/constructor/construct.py +++ b/constructor/construct.py @@ -15,6 +15,11 @@ from constructor.exceptions import UnableToParse, UnableToParseMissingJinja2, YamlParsingError from constructor.utils import yaml +WIN_SIGNTOOLS = [ + "azuresigntool", + "signtool", +] + # list of tuples (key name, required, type, description) KEYS = [ ('name', True, str, ''' @@ -242,14 +247,18 @@ `Developer ID Application: Name of the owner (XXXXXX)`. '''), - ('signing_certificate', False, str, ''' -On Windows only, set this key to the path of a PFX certificate to be used with `signtool`. -Additional environment variables can be used to configure this step, namely: + ('windows_signing_tool', False, str, f''' +The tool used to sign Windows installers. Must be one of: {", ".join(WIN_SIGNTOOLS)}. +Some tools require `signing_certificate` to be set. +Defaults to `signtool` if `signing_certificate` is set. +Additional environment variables may need to be used to configure signing. +See the documentation for details: +https://conda.github.io/constructor/howto/#signing-exe-installers +'''), -- `CONSTRUCTOR_PFX_CERTIFICATE_PASSWORD` (password to unlock the certificate, if needed) -- `CONSTRUCTOR_SIGNTOOL_PATH` (absolute path to `signtool.exe`, in case is not in `PATH`) -- `CONSTRUCTOR_SIGNTOOL_TIMESTAMP_SERVER_URL` (custom RFC 3161 timestamping server, default is -http://timestamp.sectigo.com) + ('signing_certificate', False, str, ''' +On Windows only, set this key to the path of the certificate file to be used +with the `windows_signing_tool`. '''), ('attempt_hardlinks', False, (bool, str), ''' @@ -792,6 +801,15 @@ def verify(info): types_str = " or ".join([type_.__name__ for type_ in types]) sys.exit(f"Value for 'extra_envs.{env_name}.{key}' " f"must be an instance of {types_str}") + if signtool := info.get("windows_signing_tool"): + if signtool.lower().replace(".exe", "") not in WIN_SIGNTOOLS: + sys.exit( + "Value for 'windows_signing_tool' must be one of: " + f"{', '.join(WIN_SIGNTOOLS)}. You tried to use: {signtool}." + ) + need_cert_file = ["signtool"] + if signtool in need_cert_file and not info.get("signing_certificate"): + sys.exit(f"The signing tool {signtool} requires 'signing_certificate' to be set.") def generate_doc(): diff --git a/constructor/main.py b/constructor/main.py index 76f483aea..c2223a6bf 100644 --- a/constructor/main.py +++ b/constructor/main.py @@ -109,6 +109,12 @@ def main_build(dir_path, output_dir='.', platform=cc_platform, if info.get(key): # only join if there's a truthy value set info[key] = abspath(join(dir_path, info[key])) + # Normalize name and set default value + if info.get("windows_signing_tool"): + info["windows_signing_tool"] = info["windows_signing_tool"].lower().replace(".exe", "") + elif info.get("signing_certificate"): + info["windows_signing_tool"] = "signtool" + for key in 'specs', 'packages': if key not in info: continue diff --git a/constructor/signing.py b/constructor/signing.py new file mode 100644 index 000000000..31e4e488f --- /dev/null +++ b/constructor/signing.py @@ -0,0 +1,220 @@ +import logging +import os +import shutil +from pathlib import Path +from subprocess import PIPE, STDOUT, check_call, run +from typing import Union + +from .utils import check_required_env_vars, win_str_esc + +logger = logging.getLogger(__name__) + + +class SigningTool: + """Base class to sign installers. + + Attributes + ---------- + executable: str | Path + Path to the signing tool binary. + certificate_file: str | Path + Path to the certificate file + """ + def __init__( + self, + executable: Union[str, Path], + certificate_file: Union[str, Path] = None, + ): + self.executable = str(executable) + if certificate_file and not Path(certificate_file).exists(): + raise FileNotFoundError(f"Certificate file {certificate_file} does not exist.") + self.certificate_file = certificate_file + + def _verify_tool_is_available(self): + """Helper function to verify that the signing tool executable exists. + + This is a minimum verification step and should be done even if other steps are performed + to verify the signing tool (e.g., signtool.exe /?) to receive better error messages. + For example, using `signtool.exe /?` when the path does not exist, results in a misleading + Permission Denied error. + """ + logger.info(f"Checking for {self.executable}...") + if not shutil.which(self.executable): + raise FileNotFoundError( + f"Could not find {self.executable}. Verify that the file exists or is in PATH." + ) + + def verify_signing_tool(self): + """Verify that the signing tool is usable.""" + self._verify_tool_is_available() + + def get_signing_command(self): + """Get the string of the signing command to be executed. + + For Windows, this command is inserted into the NSIS template. + """ + return self.executable + + def verify_signature(self): + """Verify the signed installer.""" + raise NotImplementedError("Signature verification not implemented for base class.") + + +class WindowsSignTool(SigningTool): + def __init__(self, certificate_file=None): + super().__init__( + os.environ.get("CONSTRUCTOR_SIGNTOOL_PATH", "signtool"), + certificate_file=certificate_file, + ) + + def get_signing_command(self) -> str: + timestamp_server = os.environ.get( + "CONSTRUCTOR_SIGNTOOL_TIMESTAMP_SERVER_URL", + "http://timestamp.sectigo.com" + ) + timestamp_digest = os.environ.get( + "CONSTRUCTOR_SIGNTOOL_TIMESTAMP_DIGEST", + "sha256" + ) + file_digest = os.environ.get( + "CONSTRUCTOR_SIGNTOOL_FILE_DIGEST", + "sha256" + ) + command = ( + f"{win_str_esc(self.executable)} sign /f {win_str_esc(self.certificate_file)} " + f"/tr {win_str_esc(timestamp_server)} /td {timestamp_digest} /fd {file_digest}" + ) + if "CONSTRUCTOR_PFX_CERTIFICATE_PASSWORD" in os.environ: + # signtool can get the password from the env var on its own + command += ' /p "%CONSTRUCTOR_PFX_CERTIFICATE_PASSWORD%"' + return command + + def verify_signing_tool(self): + super()._verify_tool_is_available() + if not Path(self.certificate_file).exists(): + raise FileNotFoundError(f"Could not find certificate file {self.certificate_file}.") + check_call([self.executable, "/?"], stdout=PIPE, stderr=PIPE) + + def verify_signature(self, installer_file: Union[str, Path]): + proc = run( + [self.executable, "verify", "/v", str(installer_file)], + stdout=PIPE, + stderr=STDOUT, + text=True, + ) + logger.info(proc.stdout) + if "SignTool Error: No signature found" in proc.stdout: + # This is a signing error! + proc.check_returncode() + elif proc.returncode: + # we had errors but maybe not critical ones + logger.error( + f"SignTool could find a signature in {installer_file} but detected errors. " + "This is expected for untrusted (development) certificates. " + "If it is supposed to be trusted, please check your certificate!" + ) + + +class AzureSignTool(SigningTool): + def __init__(self): + super().__init__(os.environ.get("AZURE_SIGNTOOL_PATH", "AzureSignTool")) + + def get_signing_command(self) -> str: + + required_env_vars = ( + "AZURE_SIGNTOOL_KEY_VAULT_URL", + "AZURE_SIGNTOOL_KEY_VAULT_CERTIFICATE", + ) + check_required_env_vars(required_env_vars) + timestamp_server = os.environ.get( + "AZURE_SIGNTOOL_TIMESTAMP_SERVER_URL", + "http://timestamp.sectigo.com" + ) + timestamp_digest = os.environ.get( + "AZURE_SIGNTOOL_TIMESTAMP_DIGEST", + "sha256" + ) + file_digest = os.environ.get( + "AZURE_SIGNTOOL_FILE_DIGEST", + "sha256" + ) + + command = ( + f"{win_str_esc(self.executable)} sign -v" + ' -kvu "%AZURE_SIGNTOOL_KEY_VAULT_URL%"' + ' -kvc "%AZURE_SIGNTOOL_KEY_VAULT_CERTIFICATE%"' + f' -tr "{timestamp_server}"' + f" -td {timestamp_digest}" + f" -fd {file_digest}" + ) + # There are three ways to sign: + # 1. Access token + # 2. Secret (requires tenant ID) + # 3. Managed identity (requires prior login to Azure) + if "AZURE_SIGNTOOL_KEY_VAULT_ACCESSTOKEN" in os.environ: + logger.info("AzureSignTool: signing binary using access token.") + command += ' -kva "%AZURE_SIGNTOOL_KEY_VAULT_ACCESSTOKEN%"' + elif "AZURE_SIGNTOOL_KEY_VAULT_SECRET" in os.environ: + # Authentication via secret required client and tenant ID + logger.info("AzureSignTool: signing binary using secret.") + required_env_vars = ( + "AZURE_SIGNTOOL_KEY_VAULT_CLIENT_ID", + "AZURE_SIGNTOOL_KEY_VAULT_TENANT_ID", + ) + check_required_env_vars(required_env_vars) + command += ( + ' -kvi "%AZURE_SIGNTOOL_KEY_VAULT_CLIENT_ID%"' + ' -kvt "%AZURE_SIGNTOOL_KEY_VAULT_TENANT_ID%"' + ' -kvs "%AZURE_SIGNTOOL_KEY_VAULT_SECRET%"' + ) + else: + # No token or secret found, assume managed identity + logger.info("AzureSignTool: signing binary using managed identity.") + command += " -kvm" + return command + + def verify_signing_tool(self): + self._verify_tool_is_available() + check_call([self.executable, "--help"], stdout=PIPE, stderr=PIPE) + + def verify_signature(self, installer_file: Union[str, Path]): + """Use Powershell to verify signature. + + For available statuses, see the Microsoft documentation: + https://learn.microsoft.com/en-us/dotnet/api/system.management.automation.signaturestatus + """ + if shutil.which("powershell") is None: + logger.error("Could not verify signature: PowerShell not found.") + return + command = ( + f"$sig = Get-AuthenticodeSignature -LiteralPath {installer_file};" + "$sig.Status.value__;" + "$sig.StatusMessage" + ) + proc = run([ + "powershell", + "-c", + command, + ], + capture_output=True, + text=True, + ) + # The return code will always be 0, + # but stderr will be non-empty on errors + if proc.stderr: + raise RuntimeError(f"Signature verification failed.\n{proc.stderr}") + try: + status, status_message = proc.stdout.strip().split("\n") + status = int(status) + if status > 1: + # Includes missing signature + raise RuntimeError(f"Error signing {installer_file}: {status_message}") + elif status == 1: + logger.error( + f"{installer_file} contains a signature that is either invalid or not trusted. " + "This is expected with development certificates. " + "If it is supposed to be trusted, please check your certificate!" + ) + except ValueError: + # Something else is in the output + raise RuntimeError(f"Unexpected signature verification output: {proc.stdout}") diff --git a/constructor/utils.py b/constructor/utils.py index 395fa0841..90c4b8cea 100644 --- a/constructor/utils.py +++ b/constructor/utils.py @@ -10,7 +10,7 @@ import re import sys from io import StringIO -from os import sep, unlink +from os import environ, sep, unlink from os.path import basename, isdir, isfile, islink, join, normpath from shutil import rmtree from subprocess import check_call, check_output @@ -265,3 +265,20 @@ def identify_conda_exe(conda_exe=None): name = "micromamba" version = output.strip() return name, version + + +def win_str_esc(s, newlines=True): + maps = [('$', '$$'), ('"', '$\\"'), ('\t', '$\\t')] + if newlines: + maps.extend([('\n', '$\\n'), ('\r', '$\\r')]) + for a, b in maps: + s = s.replace(a, b) + return '"%s"' % s + + +def check_required_env_vars(env_vars): + missing_vars = {var for var in env_vars if var not in environ} + if missing_vars: + raise RuntimeError( + f"Missing required environment variables {', '.join(missing_vars)}." + ) diff --git a/constructor/winexe.py b/constructor/winexe.py index 93a6d6a1b..3afbd8f9a 100644 --- a/constructor/winexe.py +++ b/constructor/winexe.py @@ -11,13 +11,14 @@ import tempfile from os.path import abspath, dirname, isfile, join from pathlib import Path -from subprocess import PIPE, STDOUT, check_call, check_output, run -from typing import List +from subprocess import check_output, run +from typing import List, Union from .construct import ns_platform from .imaging import write_images from .preconda import copy_extra_files from .preconda import write_files as preconda_write_files +from .signing import AzureSignTool, WindowsSignTool from .utils import ( add_condarc, approx_size_kb, @@ -27,6 +28,7 @@ make_VIProductVersion, preprocess, shortcuts_flags, + win_str_esc, ) NSIS_DIR = join(abspath(dirname(__file__)), 'nsis') @@ -35,15 +37,6 @@ logger = logging.getLogger(__name__) -def str_esc(s, newlines=True): - maps = [('$', '$$'), ('"', '$\\"'), ('\t', '$\\t')] - if newlines: - maps.extend([('\n', '$\\n'), ('\r', '$\\r')]) - for a, b in maps: - s = s.replace(a, b) - return '"%s"' % s - - def read_nsi_tmpl(info) -> str: path = abspath(info.get('nsis_template', join(NSIS_DIR, 'main.nsi.tmpl'))) logger.info('Reading: %s', path) @@ -53,7 +46,7 @@ def read_nsi_tmpl(info) -> str: def pkg_commands(download_dir, dists): for fn in dists: - yield 'File %s' % str_esc(join(download_dir, fn)) + yield 'File %s' % win_str_esc(join(download_dir, fn)) def extra_files_commands(paths, common_parent): @@ -101,7 +94,7 @@ def setup_script_env_variables(info) -> List[str]: for name, value in info.get('script_env_variables', {}).items(): lines.append( "System::Call 'kernel32::SetEnvironmentVariable(t,t)i" - + f"""("{name}", {str_esc(value)}).r0'""") + + f"""("{name}", {win_str_esc(value)}).r0'""") return lines @@ -217,27 +210,13 @@ def uninstall_menus_commands(info): return [line.strip() for line in lines] -def signtool_command(info): - "Generates a signtool command to be used in the NSIS template" - pfx_certificate = info.get("signing_certificate") - if pfx_certificate: - signtool = os.environ.get("CONSTRUCTOR_SIGNTOOL_PATH", "signtool") - timestamp_server = os.environ.get( - "CONSTRUCTOR_SIGNTOOL_TIMESTAMP_SERVER_URL", - "http://timestamp.sectigo.com" - ) - command = ( - f'{str_esc(signtool)} sign /f {str_esc(pfx_certificate)} ' - f'/tr {str_esc(timestamp_server)} /td sha256 /fd sha256' - ) - if "CONSTRUCTOR_PFX_CERTIFICATE_PASSWORD" in os.environ: - # signtool can get the password from the env var on its own - command += ' /p "%CONSTRUCTOR_PFX_CERTIFICATE_PASSWORD%"' - return command - return "" - - -def make_nsi(info, dir_path, extra_files=None, temp_extra_files=None): +def make_nsi( + info: dict, + dir_path: str, + extra_files: List = None, + temp_extra_files: List = None, + signing_tool: Union[AzureSignTool, WindowsSignTool] = None, +): "Creates the tmp/main.nsi from the template file" if extra_files is None: @@ -335,7 +314,7 @@ def make_nsi(info, dir_path, extra_files=None, temp_extra_files=None): for key, value in replace.items(): if value.startswith('@'): value = join(dir_path, value[1:]) - replace[key] = str_esc(value) + replace[key] = win_str_esc(value) data = read_nsi_tmpl(info) ppd = ns_platform(info['_platform']) @@ -372,7 +351,7 @@ def make_nsi(info, dir_path, extra_files=None, temp_extra_files=None): ('@NSIS_DIR@', NSIS_DIR), ('@BITS@', str(arch)), ('@PKG_COMMANDS@', '\n '.join(pkg_commands(download_dir, dists))), - ('@SIGNTOOL_COMMAND@', signtool_command(info)), + ('@SIGNTOOL_COMMAND@', signing_tool.get_signing_command() if signing_tool else ""), ('@SETUP_ENVS@', '\n '.join(setup_envs_commands(info, dir_path))), ('@WRITE_CONDARC@', '\n '.join(add_condarc(info))), ('@SIZE@', str(approx_pkgs_size_kb)), @@ -437,36 +416,19 @@ def verify_nsis_install(): sys.exit("Error: no file untgz.dll") -def verify_signtool_is_available(info): - if not info.get("signing_certificate"): - return - signtool = os.environ.get("CONSTRUCTOR_SIGNTOOL_PATH", "signtool") - logger.info("Checking for '%s'...", signtool) - check_call([signtool, "/?"], stdout=PIPE, stderr=PIPE) - - -def verify_installer_signature(path): - """ - Verify installer was properly signed by NSIS - We'll assume that the uninstaller was handled in the same way - """ - signtool = os.environ.get("CONSTRUCTOR_SIGNTOOL_PATH", "signtool") - p = run([signtool, "verify", "/v", path], stdout=PIPE, stderr=STDOUT, text=True) - logger.info(p.stdout) - if "SignTool Error: No signature found" in p.stdout: - # This is a signing error! - p.check_returncode() - elif p.returncode: - # we had errors but maybe not critical ones - logger.error( - "SignTool could find a signature in %s but detected errors. " - "Please check your certificate!", path - ) - - def create(info, verbose=False): verify_nsis_install() - verify_signtool_is_available(info) + signing_tool = None + if signing_tool_name := info.get("windows_signing_tool"): + if signing_tool_name == "signtool": + signing_tool = WindowsSignTool( + certificate_file=info.get("signing_certificate") + ) + elif signing_tool_name == "azuresigntool": + signing_tool = AzureSignTool() + else: + raise ValueError(f"Unknown signing tool: {signing_tool_name}") + signing_tool.verify_signing_tool() tmp_dir = tempfile.mkdtemp() preconda_write_files(info, tmp_dir) copied_extra_files = copy_extra_files(info.get("extra_files", []), tmp_dir) @@ -498,6 +460,7 @@ def create(info, verbose=False): tmp_dir, extra_files=copied_extra_files, temp_extra_files=copied_temp_extra_files, + signing_tool=signing_tool, ) verbosity = f"{'/' if sys.platform == 'win32' else '-'}V{4 if verbose else 2}" args = [MAKENSIS_EXE, verbosity, nsi] @@ -507,8 +470,8 @@ def create(info, verbose=False): logger.debug("makensis stderr:\n'%s'", process.stderr) process.check_returncode() - if info.get("signing_certificate"): - verify_installer_signature(info['_outpath']) + if signing_tool: + signing_tool.verify_signature(info['_outpath']) shutil.rmtree(tmp_dir) diff --git a/docs/source/construct-yaml.md b/docs/source/construct-yaml.md index 6497acd5d..9e02ff8e2 100644 --- a/docs/source/construct-yaml.md +++ b/docs/source/construct-yaml.md @@ -332,18 +332,25 @@ to sign `conda.exe`. For this, you need an "Application certificate" (different "Installer certificate" mentioned above). Common values for this option follow the format `Developer ID Application: Name of the owner (XXXXXX)`. -### `signing_certificate` +### `windows_signing_tool` _required:_ no
_type:_ string
-On Windows only, set this key to the path of a PFX certificate to be used with `signtool`. -Additional environment variables can be used to configure this step, namely: +The tool used to sign Windows installers. Must be one of: azuresigntool, signtool. +Some tools require `signing_certificate` to be set. +Defaults to `signtool` if `signing_certificate` is set. +Additional environment variables may need to be used to configure signing. +See the documentation for details: +https://conda.github.io/constructor/howto/#signing-exe-installers + +### `signing_certificate` + +_required:_ no
+_type:_ string
-- `CONSTRUCTOR_PFX_CERTIFICATE_PASSWORD` (password to unlock the certificate, if needed) -- `CONSTRUCTOR_SIGNTOOL_PATH` (absolute path to `signtool.exe`, in case is not in `PATH`) -- `CONSTRUCTOR_SIGNTOOL_TIMESTAMP_SERVER_URL` (custom RFC 3161 timestamping server, default is -http://timestamp.sectigo.com) +On Windows only, set this key to the path of the certificate file to be used +with the `windows_signing_tool`. ### `attempt_hardlinks` diff --git a/docs/source/howto.md b/docs/source/howto.md index 2481439a0..c8701f00b 100644 --- a/docs/source/howto.md +++ b/docs/source/howto.md @@ -38,21 +38,63 @@ On Windows, you can also add extra pages to the installer. This is an advanced o ## Signing and notarization -Windows can trigger SmartScreen alerts for EXE installers, signed or not. It does help when they are signed, though. [Read this SO answer about SmartScreen reputation for more details](https://stackoverflow.com/questions/48946680/how-to-avoid-the-windows-defender-smartscreen-prevented-an-unrecognized-app-fro/66582477#66582477). -In the case of macOS, users might get similar warnings for PKGs if the installers are not signed _and_ notarized. However, once these two requirements are fulfilled, the warnings disappear instantly. - -`constructor` offers some configuration options to help you in this process: - -- For Windows, you will need to provide the path to your code signing certificate (PFX format) in [`signing_certificate`](construct-yaml.md#signing_certificate). -- For macOS, you will need to provide two identity names. One for the PKG signature (via [`signing_identity_name`](construct-yaml.md#signing_identity_name)), and one to pass the notarization (via [`notarization_identity_name`](construct-yaml.md#notarization_identity_name)). These can be obtained in the [Apple Developer portal](https://developer.apple.com/account/). -Once signed, you can notarize your PKG with Apple's `notarytool`. - ```{seealso} Example of a CI pipeline implementing: - [Signing on Windows](https://github.com/napari/packaging/blob/6f5fcfaf7b/.github/workflows/make_bundle_conda.yml#L390) - [Signing](https://github.com/napari/packaging/blob/6f5fcfaf7b/.github/workflows/make_bundle_conda.yml#L349) and [notarization](https://github.com/napari/packaging/blob/6f5fcfaf7b/.github/workflows/make_bundle_conda.yml#L459) on macOS ``` +### Signing EXE installers + +Windows can trigger SmartScreen alerts for EXE installers, signed or not. It does help when they are signed, though. [Read this SO answer about SmartScreen reputation for more details](https://stackoverflow.com/questions/48946680/how-to-avoid-the-windows-defender-smartscreen-prevented-an-unrecognized-app-fro/66582477#66582477). + +`constructor` supports the following tools to sign installers: + +* [SignTool](https://learn.microsoft.com/en-us/windows/win32/seccrypto/signtool) +* [AzureSignTool](https://github.com/vcsjones/AzureSignTool) + +The signtool that is used can be set in the `construct.yaml` file via the [`windows_signing_tool`](construct-yaml.md#windows_signing_tool) key. +If the [`signing_certificate`](construct-yaml.md#signing_certificate) key is set, `windows_signing_tool` defaults to `signtool`. + +For each tool, there are environment variables that may need to be set to configure signing. + +#### Environment variables for SignTool + +| Variable | Description | CLI flag | Default | +|---------------------------------------------|----------------------------------------------------------------|----------|------------------------------| +| `CONSTRUCTOR_PFX_CERTIFICATE_PASSWORD` | Password for the `pfx` certificate file. | `/p` | Empty | +| `CONSTRUCTOR_SIGNTOOL_PATH` | Path to `signtool.exe`. Needed if `signtool` is not in `PATH`. | N/A | `signtool` | +| `CONSTRUCTOR_SIGNTOOL_FILE_DIGEST` | Digest algorithm for creating the file signatures. | `/fd` | `sha256` | +| `CONSTRUCTOR_SIGNTOOL_TIMESTAMP_SERVER_URL` | URL to the RFC 3161 timestamp server. | `/tr` | http://timestamp.sectigo.com | +| `CONSTRUCTOR_SIGNTOOL_TIMESTAMP_DIGEST` | Digest algorithm for the RFC 3161 time stamp. | `/td` | `sha256` | + +#### Environment variables for AzureSignTool + +| Variable | Description | CLI flag | Default | +|----------------------------------------|---------------------------------------------------------------------------------------------|----------|------------------------------| +| `AZURE_SIGNTOOL_FILE_DIGEST` | Digest algorithm for creating the file signatures. | `-fd` | `sha256` | +| `AZURE_SIGNTOOL_KEY_VAULT_ACCESSTOKEN` | An access token used to authenticate to Azure. | `-kva` | Empty | +| `AZURE_SIGNTOOL_KEY_VAULT_CERTIFICATE` | The name of the certificate in the key vault. | `-kvc` | Empty | +| `AZURE_SIGNTOOL_KEY_VAULT_CLIENT_ID` | The client ID used to authenticate to Azure. Required for authentication with a secret. | `-kvi` | Empty | +| `AZURE_SIGNTOOL_KEY_VAULT_SECRET` | The client secret used to authenticate to Azure. Required for authentication with a secret. | `-kvs` | Empty | +| `AZURE_SIGNTOOL_KEY_VAULT_TENANT_ID` | The tenant ID used to authenticate to Azure. Required for authentication with a secret. | `-kvt` | Empty | +| `AZURE_SIGNTOOL_KEY_VAULT_URL` | The URL of the key vault with the certificate. | `-kvu` | Empty | +| `AZURE_SIGNTOOL_PATH` | Path to `AzureSignTool.exe`. Needed if `azuresigntool` is not in `PATH`. | N/A | `azuresigntool` | +| `AZURE_SIGNTOOL_TIMESTAMP_SERVER_URL` | URL to the RFC 3161 timestamp server. | `-tr` | http://timestamp.sectigo.com | +| `AZURE_SIGNTOOL_TIMESTAMP_DIGEST` | Digest algorithm for the RFC 3161 time stamp. | `-td` | `sha256` | + +:::{note} + +If neither `AZURE_SIGNTOOL_KEY_VAULT_ACCESSTOKEN` nor `AZURE_SIGNTOOL_KEY_VAULT_SECRET` are set, `constructor` will use a Managed Identity (`-kvm` CLI option). +::: +### Signing and notarizing PKG installers + +In the case of macOS, users might get warnings for PKGs if the installers are not signed _and_ notarized. However, once these two requirements are fulfilled, the warnings disappear instantly. +`constructor` offers some configuration options to help you in this process: + +You will need to provide two identity names. One for the PKG signature (via [`signing_identity_name`](construct-yaml.md#signing_identity_name)), and one to pass the notarization (via [`notarization_identity_name`](construct-yaml.md#notarization_identity_name)). These can be obtained in the [Apple Developer portal](https://developer.apple.com/account/). +Once signed, you can notarize your PKG with Apple's `notarytool`. + ## Create shortcuts On Windows, `conda` supports `menuinst 1.x` shortcuts. If a package provides a certain JSON file diff --git a/docs/source/index.md b/docs/source/index.md index d3586a53b..9c909768b 100644 --- a/docs/source/index.md +++ b/docs/source/index.md @@ -11,4 +11,5 @@ getting-started howto construct-yaml cli-options +debugging ``` diff --git a/examples/azure_signtool/construct.yaml b/examples/azure_signtool/construct.yaml new file mode 100644 index 000000000..37b3c1744 --- /dev/null +++ b/examples/azure_signtool/construct.yaml @@ -0,0 +1,8 @@ +name: Signed_AzureSignTool +version: X +installer_type: exe +channels: + - http://repo.anaconda.com/pkgs/main/ +specs: + - python +windows_signing_tool: azuresigntool # [win] diff --git a/news/771-add-azure-signtool-support b/news/771-add-azure-signtool-support new file mode 100644 index 000000000..eb7a47aaf --- /dev/null +++ b/news/771-add-azure-signtool-support @@ -0,0 +1,19 @@ +### Enhancements + +* Add support for AzureSignTool to sign Windows installers. (#767 via #771) + +### Bug fixes + +* + +### Deprecations + +* + +### Docs + +* + +### Other + +* diff --git a/tests/test_examples.py b/tests/test_examples.py index 90ea18c75..d06b9e39c 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -45,11 +45,10 @@ def _execute( cmd: Iterable[str], installer_input=None, check=True, timeout=420, **env_vars ) -> subprocess.CompletedProcess: t0 = time.time() + # The environment is not copied on Windows, so copy here to get consistent behavior + env = os.environ.copy() if env_vars: - env = os.environ.copy() env.update({k: v for (k, v) in env_vars.items() if v is not None}) - else: - env = None p = subprocess.Popen( cmd, stdin=subprocess.PIPE if installer_input else None, @@ -484,6 +483,43 @@ def test_example_signing(tmp_path, request): _run_installer(input_path, installer, install_dir, request=request) +@pytest.mark.skipif(sys.platform != "win32", reason="Windows only") +@pytest.mark.skipif( + not shutil.which("azuresigntool") and not os.environ.get("AZURE_SIGNTOOL_PATH"), + reason="AzureSignTool not available" +) +@pytest.mark.parametrize( + "auth_method", + os.environ.get("AZURE_SIGNTOOL_TEST_AUTH_METHODS", "token,secret").split(","), +) +def test_azure_signtool(tmp_path, request, monkeypatch, auth_method): + """Test signing installers with AzureSignTool. + + There are three ways to authenticate with Azure: tokens, secrets, and managed identities. + There is no good sentinel environment for manged identities, so an environment variable + is used to determine which authentication methods to test. + """ + if auth_method == "token": + if not os.environ.get("AZURE_SIGNTOOL_KEY_VAULT_ACCESSTOKEN"): + pytest.skip("No AzureSignTool token in environment.") + monkeypatch.delenv("AZURE_SIGNTOOL_KEY_VAULT_SECRET", raising=False) + elif auth_method == "secret": + if not os.environ.get("AZURE_SIGNTOOL_KEY_VAULT_SECRET"): + pytest.skip("No AzureSignTool secret in environment.") + monkeypatch.delenv("AZURE_SIGNTOOL_KEY_VAULT_ACCESSTOKEN", raising=False) + elif auth_method == "managed": + monkeypatch.delenv("AZURE_SIGNTOOL_KEY_VAULT_ACCESSTOKEN", raising=False) + monkeypatch.delenv("AZURE_SIGNTOOL_KEY_VAULT_SECRET", raising=False) + else: + pytest.skip(f"Unknown authentication method {auth_method}.") + input_path = _example_path("azure_signtool") + for installer, install_dir in create_installer( + input_path, + tmp_path, + ): + _run_installer(input_path, installer, install_dir, request=request) + + def test_example_use_channel_remap(tmp_path, request): input_path = _example_path("use_channel_remap") for installer, install_dir in create_installer(input_path, tmp_path):