diff --git a/changelog/65439.added.md b/changelog/65439.added.md new file mode 100644 index 000000000000..89cfa4668780 --- /dev/null +++ b/changelog/65439.added.md @@ -0,0 +1 @@ +Added `use_os_truststore` configuration option (default `False`) that instructs Salt to use the native operating system certificate store (Windows Certificate Store, macOS Keychain, or Linux system trust) for SSL/TLS verification instead of the bundled certifi CA bundle. Requires the `truststore` package (Python 3.10+). Also adds the `ca_truststore` grain that reports which store is active (`certifi` or `os`). diff --git a/doc/ref/configuration/master.rst b/doc/ref/configuration/master.rst index 6bef93b9f5f3..53945433376b 100644 --- a/doc/ref/configuration/master.rst +++ b/doc/ref/configuration/master.rst @@ -2360,6 +2360,72 @@ compatibility. See :ref:`tls-encryption-optimization` for detailed configuration and security information. +.. conf_master:: use_os_truststore + +``use_os_truststore`` +---------------------- + +.. versionadded:: 3008.0 + +Default: ``False`` + +If ``True``, Salt will use the native operating system certificate store for +SSL/TLS verification instead of the bundled ``certifi`` CA bundle. This is +the recommended setting for environments with transparent proxies or internal +root CAs deployed via Group Policy or a device-management system. + +Platform mapping: + +- **Windows** — Local Machine Certificate Store (CryptoAPI) +- **macOS** — Keychain +- **Linux** — ``/etc/ssl/certs`` or ``/etc/pki/tls`` + +.. code-block:: yaml + + use_os_truststore: True + +.. rubric:: Requirements + +The ``truststore`` package must be installed (Python 3.10 or newer). +If the package is not present, Salt logs a warning and falls back to +``certifi``. The ``ca_truststore`` grain reports which store is active. + +.. warning:: + + Do **not** install ``pip-system-certs`` into the Salt Python environment. + That package ships a ``.pth`` file that unconditionally activates the OS + trust store on every Python startup, before Salt reads its configuration, + completely bypassing this setting. + +.. rubric:: Interaction with ``ca_bundle`` + +An explicit ``ca_bundle: /path/to/bundle.pem`` setting always takes +precedence over ``use_os_truststore``. Use ``ca_bundle`` when you need to +pin a specific certificate file regardless of the OS store. + +.. rubric:: PKI architecture + +This setting has **no effect** on Salt's master/minion key authentication +system (``pki_dir``, AES session keys, minion key acceptance). It only +affects outbound HTTPS/TLS connections made by Salt — HTTP runner, gitfs, +fileserver backends, cloud drivers, and similar components. + +.. note:: + + On Windows, the ``LocalSystem`` service account (the default account + for the salt-master and salt-minion Windows services) only has access to + the **Local Machine** certificate store, not the Current User store. + Certificates must be deployed to the Local Machine store, for example + via Group Policy, to be visible to Salt. + +.. note:: + + On Windows, certificate verification is performed via a CryptoAPI service + call rather than a simple file read. This may add a small amount of + latency on the first TLS connection made by a new process compared with + the simple file read used with ``certifi``. On Linux and macOS the + performance difference is negligible. + .. conf_master:: preserve_minion_cache ``preserve_minion_cache`` diff --git a/doc/ref/configuration/minion.rst b/doc/ref/configuration/minion.rst index 8735f619b48c..6662fdb1638c 100644 --- a/doc/ref/configuration/minion.rst +++ b/doc/ref/configuration/minion.rst @@ -3257,6 +3257,72 @@ compatibility. See :ref:`tls-encryption-optimization` for detailed configuration and security information. +.. conf_minion:: use_os_truststore + +``use_os_truststore`` +---------------------- + +.. versionadded:: 3008.0 + +Default: ``False`` + +If ``True``, Salt will use the native operating system certificate store for +SSL/TLS verification instead of the bundled ``certifi`` CA bundle. This is +the recommended setting for environments with transparent proxies or internal +root CAs deployed via Group Policy or a device-management system. + +Platform mapping: + +- **Windows** — Local Machine Certificate Store (CryptoAPI) +- **macOS** — Keychain +- **Linux** — ``/etc/ssl/certs`` or ``/etc/pki/tls`` + +.. code-block:: yaml + + use_os_truststore: True + +.. rubric:: Requirements + +The ``truststore`` package must be installed (Python 3.10 or newer). +If the package is not present, Salt logs a warning and falls back to +``certifi``. The ``ca_truststore`` grain reports which store is active. + +.. warning:: + + Do **not** install ``pip-system-certs`` into the Salt Python environment. + That package ships a ``.pth`` file that unconditionally activates the OS + trust store on every Python startup, before Salt reads its configuration, + completely bypassing this setting. + +.. rubric:: Interaction with ``ca_bundle`` + +An explicit ``ca_bundle: /path/to/bundle.pem`` setting always takes +precedence over ``use_os_truststore``. Use ``ca_bundle`` when you need to +pin a specific certificate file regardless of the OS store. + +.. rubric:: PKI architecture + +This setting has **no effect** on Salt's master/minion key authentication +system (``pki_dir``, AES session keys, minion key acceptance). It only +affects outbound HTTPS/TLS connections made by Salt — HTTP runner, gitfs, +fileserver backends, cloud drivers, and similar components. + +.. note:: + + On Windows, the ``LocalSystem`` service account (the default account + for the salt-minion Windows service) only has access to the **Local + Machine** certificate store, not the Current User store. Certificates + must be deployed to the Local Machine store, for example via Group + Policy, to be visible to Salt. + +.. note:: + + On Windows, certificate verification is performed via a CryptoAPI service + call rather than a simple file read. This may add a small amount of + latency on the first TLS connection made by a new process compared with + the simple file read used with ``certifi``. On Linux and macOS the + performance difference is negligible. + ``encryption_algorithm`` ------------------------ diff --git a/doc/ref/grains/all/index.rst b/doc/ref/grains/all/index.rst index 1a78fc952964..eab619330f09 100644 --- a/doc/ref/grains/all/index.rst +++ b/doc/ref/grains/all/index.rst @@ -21,3 +21,4 @@ grains modules pending_reboot resources rest_sample + truststore diff --git a/doc/ref/grains/all/salt.grains.truststore.rst b/doc/ref/grains/all/salt.grains.truststore.rst new file mode 100644 index 000000000000..8bc685c178d0 --- /dev/null +++ b/doc/ref/grains/all/salt.grains.truststore.rst @@ -0,0 +1,5 @@ +salt.grains.truststore +====================== + +.. automodule:: salt.grains.truststore + :members: diff --git a/requirements/base.txt b/requirements/base.txt index dc68ba0aa486..cde110102731 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -58,6 +58,7 @@ setproctitle>=1.2.3 timelib>=0.2.5; python_version < '3.11' timelib>=0.3.0; python_version >= '3.11' tornado>=6.5.5 +truststore>=0.10.0; python_version >= "3.10" urllib3>=1.26.20,<2.0.0; python_version < '3.10' urllib3>=2.7.0; python_version >= '3.10' virtualenv diff --git a/requirements/static/ci/py3.10/cloud.txt b/requirements/static/ci/py3.10/cloud.txt index 1180f8c58643..b232c6635748 100644 --- a/requirements/static/ci/py3.10/cloud.txt +++ b/requirements/static/ci/py3.10/cloud.txt @@ -731,6 +731,11 @@ trustme==1.1.0 # via # -c requirements/static/ci/py3.10/linux.txt # -r requirements/pytest.txt +truststore==0.10.4 + # via + # -c requirements/static/ci/py3.10/linux.txt + # -c requirements/static/pkg/py3.10/linux.txt + # -r requirements/base.txt types-pyyaml==6.0.1 # via # -c requirements/static/ci/py3.10/linux.txt diff --git a/requirements/static/ci/py3.10/darwin.txt b/requirements/static/ci/py3.10/darwin.txt index 63537d04189b..cbbd0d6688c2 100644 --- a/requirements/static/ci/py3.10/darwin.txt +++ b/requirements/static/ci/py3.10/darwin.txt @@ -507,6 +507,10 @@ transitions==0.9.0 # via junos-eznc trustme==1.1.0 # via -r requirements/pytest.txt +truststore==0.10.4 + # via + # -c requirements/static/pkg/py3.10/darwin.txt + # -r requirements/base.txt types-pyyaml==6.0.1 # via responses typing-extensions==4.15.0 diff --git a/requirements/static/ci/py3.10/docs.txt b/requirements/static/ci/py3.10/docs.txt index d8cb5b0d6029..8984f756d7d2 100644 --- a/requirements/static/ci/py3.10/docs.txt +++ b/requirements/static/ci/py3.10/docs.txt @@ -310,6 +310,10 @@ tornado==6.5.5 # via # -c requirements/static/ci/py3.10/linux.txt # -r requirements/base.txt +truststore==0.10.4 + # via + # -c requirements/static/ci/py3.10/linux.txt + # -r requirements/base.txt typing-extensions==4.15.0 # via # -c requirements/static/ci/py3.10/linux.txt diff --git a/requirements/static/ci/py3.10/freebsd.txt b/requirements/static/ci/py3.10/freebsd.txt index cea10615417c..dfb0de30c87a 100644 --- a/requirements/static/ci/py3.10/freebsd.txt +++ b/requirements/static/ci/py3.10/freebsd.txt @@ -567,6 +567,10 @@ transitions==0.9.0 ; sys_platform != 'win32' # via junos-eznc trustme==1.1.0 # via -r requirements/pytest.txt +truststore==0.10.4 + # via + # -c requirements/static/pkg/py3.10/freebsd.txt + # -r requirements/base.txt types-pyyaml==6.0.1 # via responses typing-extensions==4.15.0 diff --git a/requirements/static/ci/py3.10/lint.txt b/requirements/static/ci/py3.10/lint.txt index f584b8f36b67..d746307452c6 100644 --- a/requirements/static/ci/py3.10/lint.txt +++ b/requirements/static/ci/py3.10/lint.txt @@ -723,6 +723,11 @@ transitions==0.9.0 # via # -c requirements/static/ci/py3.10/linux.txt # junos-eznc +truststore==0.10.4 + # via + # -c requirements/static/ci/py3.10/linux.txt + # -c requirements/static/pkg/py3.10/linux.txt + # -r requirements/base.txt twilio==9.10.5 # via # -c requirements/static/ci/py3.10/linux.txt diff --git a/requirements/static/ci/py3.10/linux.txt b/requirements/static/ci/py3.10/linux.txt index bafef4878f80..77ab3741e658 100644 --- a/requirements/static/ci/py3.10/linux.txt +++ b/requirements/static/ci/py3.10/linux.txt @@ -571,6 +571,10 @@ transitions==0.9.0 # via junos-eznc trustme==1.1.0 # via -r requirements/pytest.txt +truststore==0.10.4 + # via + # -c requirements/static/pkg/py3.10/linux.txt + # -r requirements/base.txt twilio==9.10.5 # via -r requirements/static/ci/linux.in types-pyyaml==6.0.1 diff --git a/requirements/static/ci/py3.10/windows.txt b/requirements/static/ci/py3.10/windows.txt index ae6d448ab8a4..560685c73166 100644 --- a/requirements/static/ci/py3.10/windows.txt +++ b/requirements/static/ci/py3.10/windows.txt @@ -511,6 +511,10 @@ tornado==6.5.5 # -r requirements/base.txt trustme==1.1.0 # via -r requirements/pytest.txt +truststore==0.10.4 + # via + # -c requirements/static/pkg/py3.10/windows.txt + # -r requirements/base.txt typer==0.24.1 # via # -c requirements/static/pkg/py3.10/windows.txt diff --git a/requirements/static/ci/py3.11/cloud.txt b/requirements/static/ci/py3.11/cloud.txt index 1dd9e737bf21..f36d9c9f06fd 100644 --- a/requirements/static/ci/py3.11/cloud.txt +++ b/requirements/static/ci/py3.11/cloud.txt @@ -717,6 +717,11 @@ trustme==1.1.0 # via # -c requirements/static/ci/py3.11/linux.txt # -r requirements/pytest.txt +truststore==0.10.4 + # via + # -c requirements/static/ci/py3.11/linux.txt + # -c requirements/static/pkg/py3.11/linux.txt + # -r requirements/base.txt types-pyyaml==6.0.12.12 # via # -c requirements/static/ci/py3.11/linux.txt diff --git a/requirements/static/ci/py3.11/darwin.txt b/requirements/static/ci/py3.11/darwin.txt index bef0844f52f3..18aded0c70db 100644 --- a/requirements/static/ci/py3.11/darwin.txt +++ b/requirements/static/ci/py3.11/darwin.txt @@ -498,6 +498,10 @@ transitions==0.9.0 # via junos-eznc trustme==1.1.0 # via -r requirements/pytest.txt +truststore==0.10.4 + # via + # -c requirements/static/pkg/py3.11/darwin.txt + # -r requirements/base.txt types-pyyaml==6.0.12.12 # via responses typing-extensions==4.14.1 diff --git a/requirements/static/ci/py3.11/docs.txt b/requirements/static/ci/py3.11/docs.txt index fa88d806d80f..6cec13c481af 100644 --- a/requirements/static/ci/py3.11/docs.txt +++ b/requirements/static/ci/py3.11/docs.txt @@ -306,6 +306,10 @@ tornado==6.5.5 # via # -c requirements/static/ci/py3.11/linux.txt # -r requirements/base.txt +truststore==0.10.4 + # via + # -c requirements/static/ci/py3.11/linux.txt + # -r requirements/base.txt typing-extensions==4.14.1 # via # -c requirements/static/ci/py3.11/linux.txt diff --git a/requirements/static/ci/py3.11/freebsd.txt b/requirements/static/ci/py3.11/freebsd.txt index f243b8686d8b..417e72826814 100644 --- a/requirements/static/ci/py3.11/freebsd.txt +++ b/requirements/static/ci/py3.11/freebsd.txt @@ -541,6 +541,10 @@ transitions==0.9.0 ; sys_platform != 'win32' # via junos-eznc trustme==1.1.0 # via -r requirements/pytest.txt +truststore==0.10.4 + # via + # -c requirements/static/pkg/py3.11/freebsd.txt + # -r requirements/base.txt types-pyyaml==6.0.12.12 # via responses typing-extensions==4.14.1 diff --git a/requirements/static/ci/py3.11/lint.txt b/requirements/static/ci/py3.11/lint.txt index 716a73afbdce..4985e35618a3 100644 --- a/requirements/static/ci/py3.11/lint.txt +++ b/requirements/static/ci/py3.11/lint.txt @@ -710,6 +710,11 @@ transitions==0.9.0 # via # -c requirements/static/ci/py3.11/linux.txt # junos-eznc +truststore==0.10.4 + # via + # -c requirements/static/ci/py3.11/linux.txt + # -c requirements/static/pkg/py3.11/linux.txt + # -r requirements/base.txt twilio==9.10.5 # via # -c requirements/static/ci/py3.11/linux.txt diff --git a/requirements/static/ci/py3.11/linux.txt b/requirements/static/ci/py3.11/linux.txt index 0722975f77c5..c057c055f0ba 100644 --- a/requirements/static/ci/py3.11/linux.txt +++ b/requirements/static/ci/py3.11/linux.txt @@ -560,6 +560,10 @@ transitions==0.9.0 # via junos-eznc trustme==1.1.0 # via -r requirements/pytest.txt +truststore==0.10.4 + # via + # -c requirements/static/pkg/py3.11/linux.txt + # -r requirements/base.txt twilio==9.10.5 # via -r requirements/static/ci/linux.in types-pyyaml==6.0.12.12 diff --git a/requirements/static/ci/py3.11/windows.txt b/requirements/static/ci/py3.11/windows.txt index 0c419d1c99e3..db641de05801 100644 --- a/requirements/static/ci/py3.11/windows.txt +++ b/requirements/static/ci/py3.11/windows.txt @@ -502,6 +502,10 @@ tornado==6.5.5 # -r requirements/base.txt trustme==1.1.0 # via -r requirements/pytest.txt +truststore==0.10.4 + # via + # -c requirements/static/pkg/py3.11/windows.txt + # -r requirements/base.txt typer==0.24.1 # via # -c requirements/static/pkg/py3.11/windows.txt diff --git a/requirements/static/ci/py3.12/cloud.txt b/requirements/static/ci/py3.12/cloud.txt index 77070f4e0736..b43858e0910d 100644 --- a/requirements/static/ci/py3.12/cloud.txt +++ b/requirements/static/ci/py3.12/cloud.txt @@ -712,6 +712,11 @@ trustme==1.1.0 # via # -c requirements/static/ci/py3.12/linux.txt # -r requirements/pytest.txt +truststore==0.10.4 + # via + # -c requirements/static/ci/py3.12/linux.txt + # -c requirements/static/pkg/py3.12/linux.txt + # -r requirements/base.txt types-pyyaml==6.0.12.12 # via # -c requirements/static/ci/py3.12/linux.txt diff --git a/requirements/static/ci/py3.12/darwin.txt b/requirements/static/ci/py3.12/darwin.txt index 6e9ba2b3b4d4..71f78971a215 100644 --- a/requirements/static/ci/py3.12/darwin.txt +++ b/requirements/static/ci/py3.12/darwin.txt @@ -494,6 +494,10 @@ transitions==0.9.0 # via junos-eznc trustme==1.1.0 # via -r requirements/pytest.txt +truststore==0.10.4 + # via + # -c requirements/static/pkg/py3.12/darwin.txt + # -r requirements/base.txt types-pyyaml==6.0.12.12 # via responses typing-extensions==4.14.1 diff --git a/requirements/static/ci/py3.12/docs.txt b/requirements/static/ci/py3.12/docs.txt index c6c8030e2ffb..d533f840b281 100644 --- a/requirements/static/ci/py3.12/docs.txt +++ b/requirements/static/ci/py3.12/docs.txt @@ -302,6 +302,10 @@ tornado==6.5.5 # via # -c requirements/static/ci/py3.12/linux.txt # -r requirements/base.txt +truststore==0.10.4 + # via + # -c requirements/static/ci/py3.12/linux.txt + # -r requirements/base.txt typing-extensions==4.14.1 # via # -c requirements/static/ci/py3.12/linux.txt diff --git a/requirements/static/ci/py3.12/freebsd.txt b/requirements/static/ci/py3.12/freebsd.txt index 159f8ee6c6fc..fe2e953b9b27 100644 --- a/requirements/static/ci/py3.12/freebsd.txt +++ b/requirements/static/ci/py3.12/freebsd.txt @@ -537,6 +537,10 @@ transitions==0.9.0 ; sys_platform != 'win32' # via junos-eznc trustme==1.1.0 # via -r requirements/pytest.txt +truststore==0.10.4 + # via + # -c requirements/static/pkg/py3.12/freebsd.txt + # -r requirements/base.txt types-pyyaml==6.0.12.12 # via responses typing-extensions==4.14.1 diff --git a/requirements/static/ci/py3.12/lint.txt b/requirements/static/ci/py3.12/lint.txt index 6b51e879edc2..e97273ab4a93 100644 --- a/requirements/static/ci/py3.12/lint.txt +++ b/requirements/static/ci/py3.12/lint.txt @@ -705,6 +705,11 @@ transitions==0.9.0 # via # -c requirements/static/ci/py3.12/linux.txt # junos-eznc +truststore==0.10.4 + # via + # -c requirements/static/ci/py3.12/linux.txt + # -c requirements/static/pkg/py3.12/linux.txt + # -r requirements/base.txt twilio==9.10.5 # via # -c requirements/static/ci/py3.12/linux.txt diff --git a/requirements/static/ci/py3.12/linux.txt b/requirements/static/ci/py3.12/linux.txt index 8bafcdd4e46b..44dce41e95dc 100644 --- a/requirements/static/ci/py3.12/linux.txt +++ b/requirements/static/ci/py3.12/linux.txt @@ -556,6 +556,10 @@ transitions==0.9.0 # via junos-eznc trustme==1.1.0 # via -r requirements/pytest.txt +truststore==0.10.4 + # via + # -c requirements/static/pkg/py3.12/linux.txt + # -r requirements/base.txt twilio==9.10.5 # via -r requirements/static/ci/linux.in types-pyyaml==6.0.12.12 diff --git a/requirements/static/ci/py3.12/windows.txt b/requirements/static/ci/py3.12/windows.txt index 5528f94bf3b5..38e34c5715b4 100644 --- a/requirements/static/ci/py3.12/windows.txt +++ b/requirements/static/ci/py3.12/windows.txt @@ -495,6 +495,10 @@ tornado==6.5.5 # -r requirements/base.txt trustme==1.1.0 # via -r requirements/pytest.txt +truststore==0.10.4 + # via + # -c requirements/static/pkg/py3.12/windows.txt + # -r requirements/base.txt typer==0.24.1 # via # -c requirements/static/pkg/py3.12/windows.txt diff --git a/requirements/static/ci/py3.13/cloud.txt b/requirements/static/ci/py3.13/cloud.txt index dc653b22571e..e06b20751362 100644 --- a/requirements/static/ci/py3.13/cloud.txt +++ b/requirements/static/ci/py3.13/cloud.txt @@ -716,6 +716,11 @@ trustme==1.2.0 # via # -c requirements/static/ci/py3.13/linux.txt # -r requirements/pytest.txt +truststore==0.10.4 + # via + # -c requirements/static/ci/py3.13/linux.txt + # -c requirements/static/pkg/py3.13/linux.txt + # -r requirements/base.txt typing-extensions==4.12.2 # via # -c requirements/static/ci/py3.13/linux.txt diff --git a/requirements/static/ci/py3.13/darwin.txt b/requirements/static/ci/py3.13/darwin.txt index 1b186ae1193b..c8f8676fd0c1 100644 --- a/requirements/static/ci/py3.13/darwin.txt +++ b/requirements/static/ci/py3.13/darwin.txt @@ -500,6 +500,10 @@ transitions==0.9.2 # via junos-eznc trustme==1.2.0 # via -r requirements/pytest.txt +truststore==0.10.4 + # via + # -c requirements/static/pkg/py3.13/darwin.txt + # -r requirements/base.txt typing-extensions==4.12.2 # via pytest-system-statistics urllib3==2.7.0 diff --git a/requirements/static/ci/py3.13/docs.txt b/requirements/static/ci/py3.13/docs.txt index 626a0cb0d52b..7fedab12688c 100644 --- a/requirements/static/ci/py3.13/docs.txt +++ b/requirements/static/ci/py3.13/docs.txt @@ -302,6 +302,10 @@ tornado==6.5.5 # via # -c requirements/static/ci/py3.13/linux.txt # -r requirements/base.txt +truststore==0.10.4 + # via + # -c requirements/static/ci/py3.13/linux.txt + # -r requirements/base.txt uc-micro-py==1.0.3 # via linkify-it-py urllib3==2.7.0 diff --git a/requirements/static/ci/py3.13/freebsd.txt b/requirements/static/ci/py3.13/freebsd.txt index 90155f003d26..4363734650b9 100644 --- a/requirements/static/ci/py3.13/freebsd.txt +++ b/requirements/static/ci/py3.13/freebsd.txt @@ -542,6 +542,10 @@ transitions==0.9.2 ; sys_platform != 'win32' # via junos-eznc trustme==1.2.0 # via -r requirements/pytest.txt +truststore==0.10.4 + # via + # -c requirements/static/pkg/py3.13/freebsd.txt + # -r requirements/base.txt typing-extensions==4.12.2 # via pytest-system-statistics urllib3==2.7.0 diff --git a/requirements/static/ci/py3.13/lint.txt b/requirements/static/ci/py3.13/lint.txt index c6de1789b466..3b242be7f74d 100644 --- a/requirements/static/ci/py3.13/lint.txt +++ b/requirements/static/ci/py3.13/lint.txt @@ -708,6 +708,11 @@ transitions==0.9.2 # via # -c requirements/static/ci/py3.13/linux.txt # junos-eznc +truststore==0.10.4 + # via + # -c requirements/static/ci/py3.13/linux.txt + # -c requirements/static/pkg/py3.13/linux.txt + # -r requirements/base.txt twilio==9.10.5 # via # -c requirements/static/ci/py3.13/linux.txt diff --git a/requirements/static/ci/py3.13/linux.txt b/requirements/static/ci/py3.13/linux.txt index 7da6dbaf1a7c..480807a9bc9c 100644 --- a/requirements/static/ci/py3.13/linux.txt +++ b/requirements/static/ci/py3.13/linux.txt @@ -560,6 +560,10 @@ transitions==0.9.2 # via junos-eznc trustme==1.2.0 # via -r requirements/pytest.txt +truststore==0.10.4 + # via + # -c requirements/static/pkg/py3.13/linux.txt + # -r requirements/base.txt twilio==9.10.5 # via -r requirements/static/ci/linux.in typing-extensions==4.12.2 diff --git a/requirements/static/ci/py3.13/windows.txt b/requirements/static/ci/py3.13/windows.txt index 6841ba7931ad..5ac69524bbed 100644 --- a/requirements/static/ci/py3.13/windows.txt +++ b/requirements/static/ci/py3.13/windows.txt @@ -505,6 +505,10 @@ tornado==6.5.5 # -r requirements/base.txt trustme==1.2.0 # via -r requirements/pytest.txt +truststore==0.10.4 + # via + # -c requirements/static/pkg/py3.13/windows.txt + # -r requirements/base.txt typer==0.24.1 # via # -c requirements/static/pkg/py3.13/windows.txt diff --git a/requirements/static/ci/py3.14/cloud.txt b/requirements/static/ci/py3.14/cloud.txt index 205f9e194ffe..c84409983b38 100644 --- a/requirements/static/ci/py3.14/cloud.txt +++ b/requirements/static/ci/py3.14/cloud.txt @@ -741,6 +741,11 @@ trustme==1.2.1 # via # -c requirements/static/ci/py3.14/linux.txt # -r requirements/pytest.txt +truststore==0.10.4 + # via + # -c requirements/static/ci/py3.14/linux.txt + # -c requirements/static/pkg/py3.14/linux.txt + # -r requirements/base.txt typer==0.25.1 # via # -c requirements/static/ci/py3.14/linux.txt diff --git a/requirements/static/ci/py3.14/darwin.txt b/requirements/static/ci/py3.14/darwin.txt index 59741d80e331..b9b461a40346 100644 --- a/requirements/static/ci/py3.14/darwin.txt +++ b/requirements/static/ci/py3.14/darwin.txt @@ -520,6 +520,10 @@ transitions==0.9.3 # via junos-eznc trustme==1.2.1 # via -r requirements/pytest.txt +truststore==0.10.4 + # via + # -c requirements/static/pkg/py3.14/darwin.txt + # -r requirements/base.txt typer==0.25.1 # via # -c requirements/static/pkg/py3.14/darwin.txt diff --git a/requirements/static/ci/py3.14/docs.txt b/requirements/static/ci/py3.14/docs.txt index 37554fb5721a..8fdf6d4216a4 100644 --- a/requirements/static/ci/py3.14/docs.txt +++ b/requirements/static/ci/py3.14/docs.txt @@ -322,6 +322,10 @@ tornado==6.5.5 # via # -c requirements/static/ci/py3.14/linux.txt # -r requirements/base.txt +truststore==0.10.4 + # via + # -c requirements/static/ci/py3.14/linux.txt + # -r requirements/base.txt typer==0.25.1 # via # -c requirements/static/ci/py3.14/linux.txt diff --git a/requirements/static/ci/py3.14/freebsd.txt b/requirements/static/ci/py3.14/freebsd.txt index 98ace4458e99..75e92099339a 100644 --- a/requirements/static/ci/py3.14/freebsd.txt +++ b/requirements/static/ci/py3.14/freebsd.txt @@ -566,6 +566,10 @@ transitions==0.9.3 ; sys_platform != 'win32' # via junos-eznc trustme==1.2.1 # via -r requirements/pytest.txt +truststore==0.10.4 + # via + # -c requirements/static/pkg/py3.14/freebsd.txt + # -r requirements/base.txt typer==0.25.1 # via # -c requirements/static/pkg/py3.14/freebsd.txt diff --git a/requirements/static/ci/py3.14/lint.txt b/requirements/static/ci/py3.14/lint.txt index e546ecb8ac48..76b504710029 100644 --- a/requirements/static/ci/py3.14/lint.txt +++ b/requirements/static/ci/py3.14/lint.txt @@ -728,6 +728,11 @@ transitions==0.9.3 # via # -c requirements/static/ci/py3.14/linux.txt # junos-eznc +truststore==0.10.4 + # via + # -c requirements/static/ci/py3.14/linux.txt + # -c requirements/static/pkg/py3.14/linux.txt + # -r requirements/base.txt twilio==9.10.5 # via # -c requirements/static/ci/py3.14/linux.txt diff --git a/requirements/static/ci/py3.14/linux.txt b/requirements/static/ci/py3.14/linux.txt index 31f0f72accd7..68adca362a1a 100644 --- a/requirements/static/ci/py3.14/linux.txt +++ b/requirements/static/ci/py3.14/linux.txt @@ -578,6 +578,10 @@ transitions==0.9.3 # via junos-eznc trustme==1.2.1 # via -r requirements/pytest.txt +truststore==0.10.4 + # via + # -c requirements/static/pkg/py3.14/linux.txt + # -r requirements/base.txt twilio==9.10.5 # via -r requirements/static/ci/linux.in typer==0.25.1 diff --git a/requirements/static/ci/py3.14/windows.txt b/requirements/static/ci/py3.14/windows.txt index f31a37bcbd71..a861e4f907ad 100644 --- a/requirements/static/ci/py3.14/windows.txt +++ b/requirements/static/ci/py3.14/windows.txt @@ -505,6 +505,10 @@ tornado==6.5.5 # -r requirements/base.txt trustme==1.2.1 # via -r requirements/pytest.txt +truststore==0.10.4 + # via + # -c requirements/static/pkg/py3.14/windows.txt + # -r requirements/base.txt typer==0.25.1 # via # -c requirements/static/pkg/py3.14/windows.txt diff --git a/requirements/static/ci/py3.9/freebsd.txt b/requirements/static/ci/py3.9/freebsd.txt index 28267cc99c07..d70ded723f65 100644 --- a/requirements/static/ci/py3.9/freebsd.txt +++ b/requirements/static/ci/py3.9/freebsd.txt @@ -640,6 +640,10 @@ transitions==0.9.0 ; sys_platform != 'win32' # via junos-eznc trustme==1.1.0 # via -r requirements/pytest.txt +truststore==0.10.4 ; python_full_version >= '3.10' + # via + # -c requirements/static/pkg/py3.9/freebsd.txt + # -r requirements/base.txt ttp==0.9.5 ; python_full_version < '3.10' and sys_platform != 'win32' # via # napalm diff --git a/requirements/static/pkg/py3.10/darwin.txt b/requirements/static/pkg/py3.10/darwin.txt index 9d9b50b85de7..5b0059a587a7 100644 --- a/requirements/static/pkg/py3.10/darwin.txt +++ b/requirements/static/pkg/py3.10/darwin.txt @@ -164,6 +164,8 @@ timelib==0.3.0 # -r requirements/static/pkg/darwin.in tornado==6.5.5 # via -r requirements/base.txt +truststore==0.10.4 + # via -r requirements/base.txt typing-extensions==4.15.0 # via # aiosignal diff --git a/requirements/static/pkg/py3.10/freebsd.txt b/requirements/static/pkg/py3.10/freebsd.txt index e456a36207e8..d587550d3c26 100644 --- a/requirements/static/pkg/py3.10/freebsd.txt +++ b/requirements/static/pkg/py3.10/freebsd.txt @@ -203,6 +203,8 @@ timelib==0.3.0 # -r requirements/static/pkg/freebsd.in tornado==6.5.5 # via -r requirements/base.txt +truststore==0.10.4 + # via -r requirements/base.txt typing-extensions==4.15.0 ; python_full_version < '3.13' # via # aiosignal diff --git a/requirements/static/pkg/py3.10/linux.txt b/requirements/static/pkg/py3.10/linux.txt index 8c52a8ad48f2..76a90e739a37 100644 --- a/requirements/static/pkg/py3.10/linux.txt +++ b/requirements/static/pkg/py3.10/linux.txt @@ -183,6 +183,8 @@ timelib==0.3.0 # -r requirements/static/pkg/linux.in tornado==6.5.5 # via -r requirements/base.txt +truststore==0.10.4 + # via -r requirements/base.txt typing-extensions==4.15.0 # via # aiosignal diff --git a/requirements/static/pkg/py3.10/windows.txt b/requirements/static/pkg/py3.10/windows.txt index 6c837023f80d..2fb30851f4a0 100644 --- a/requirements/static/pkg/py3.10/windows.txt +++ b/requirements/static/pkg/py3.10/windows.txt @@ -185,6 +185,8 @@ timelib==0.3.0 # -r requirements/static/pkg/windows.in tornado==6.5.5 # via -r requirements/base.txt +truststore==0.10.4 + # via -r requirements/base.txt typer==0.24.1 # via typer-slim typer-slim==0.24.0 diff --git a/requirements/static/pkg/py3.11/darwin.txt b/requirements/static/pkg/py3.11/darwin.txt index 852f11c3f3f8..af3fb962a5c4 100644 --- a/requirements/static/pkg/py3.11/darwin.txt +++ b/requirements/static/pkg/py3.11/darwin.txt @@ -162,6 +162,8 @@ timelib==0.3.0 # -r requirements/static/pkg/darwin.in tornado==6.5.5 # via -r requirements/base.txt +truststore==0.10.4 + # via -r requirements/base.txt typing-extensions==4.14.1 # via # aiosignal diff --git a/requirements/static/pkg/py3.11/freebsd.txt b/requirements/static/pkg/py3.11/freebsd.txt index ce933b6f8529..99dacfd5c6d7 100644 --- a/requirements/static/pkg/py3.11/freebsd.txt +++ b/requirements/static/pkg/py3.11/freebsd.txt @@ -194,6 +194,8 @@ timelib==0.3.0 # -r requirements/static/pkg/freebsd.in tornado==6.5.5 # via -r requirements/base.txt +truststore==0.10.4 + # via -r requirements/base.txt typing-extensions==4.14.1 ; python_full_version < '3.13' # via # aiosignal diff --git a/requirements/static/pkg/py3.11/linux.txt b/requirements/static/pkg/py3.11/linux.txt index 3e6d1dad7df0..f16bc1789572 100644 --- a/requirements/static/pkg/py3.11/linux.txt +++ b/requirements/static/pkg/py3.11/linux.txt @@ -181,6 +181,8 @@ timelib==0.3.0 # -r requirements/static/pkg/linux.in tornado==6.5.5 # via -r requirements/base.txt +truststore==0.10.4 + # via -r requirements/base.txt typing-extensions==4.14.1 # via # aiosignal diff --git a/requirements/static/pkg/py3.11/windows.txt b/requirements/static/pkg/py3.11/windows.txt index e594e76491b3..d1b128a00bd7 100644 --- a/requirements/static/pkg/py3.11/windows.txt +++ b/requirements/static/pkg/py3.11/windows.txt @@ -183,6 +183,8 @@ timelib==0.3.0 # -r requirements/static/pkg/windows.in tornado==6.5.5 # via -r requirements/base.txt +truststore==0.10.4 + # via -r requirements/base.txt typer==0.24.1 # via typer-slim typer-slim==0.24.0 diff --git a/requirements/static/pkg/py3.12/darwin.txt b/requirements/static/pkg/py3.12/darwin.txt index f3a17ac4440c..c5291e1427aa 100644 --- a/requirements/static/pkg/py3.12/darwin.txt +++ b/requirements/static/pkg/py3.12/darwin.txt @@ -160,6 +160,8 @@ timelib==0.3.0 # -r requirements/static/pkg/darwin.in tornado==6.5.5 # via -r requirements/base.txt +truststore==0.10.4 + # via -r requirements/base.txt typing-extensions==4.14.1 # via # aiosignal diff --git a/requirements/static/pkg/py3.12/freebsd.txt b/requirements/static/pkg/py3.12/freebsd.txt index f2c9f12c7ffd..888ca4b4f7d2 100644 --- a/requirements/static/pkg/py3.12/freebsd.txt +++ b/requirements/static/pkg/py3.12/freebsd.txt @@ -192,6 +192,8 @@ timelib==0.3.0 # -r requirements/static/pkg/freebsd.in tornado==6.5.5 # via -r requirements/base.txt +truststore==0.10.4 + # via -r requirements/base.txt typing-extensions==4.14.1 ; python_full_version < '3.13' # via # aiosignal diff --git a/requirements/static/pkg/py3.12/linux.txt b/requirements/static/pkg/py3.12/linux.txt index 4f90b887378a..fbd082203fc0 100644 --- a/requirements/static/pkg/py3.12/linux.txt +++ b/requirements/static/pkg/py3.12/linux.txt @@ -179,6 +179,8 @@ timelib==0.3.0 # -r requirements/static/pkg/linux.in tornado==6.5.5 # via -r requirements/base.txt +truststore==0.10.4 + # via -r requirements/base.txt typing-extensions==4.14.1 # via # aiosignal diff --git a/requirements/static/pkg/py3.12/windows.txt b/requirements/static/pkg/py3.12/windows.txt index 7da337b8bbbc..54af1dcddd63 100644 --- a/requirements/static/pkg/py3.12/windows.txt +++ b/requirements/static/pkg/py3.12/windows.txt @@ -181,6 +181,8 @@ timelib==0.3.0 # -r requirements/static/pkg/windows.in tornado==6.5.5 # via -r requirements/base.txt +truststore==0.10.4 + # via -r requirements/base.txt typer==0.24.1 # via typer-slim typer-slim==0.24.0 diff --git a/requirements/static/pkg/py3.13/darwin.txt b/requirements/static/pkg/py3.13/darwin.txt index 4741f5733e88..c1d4a7f2154b 100644 --- a/requirements/static/pkg/py3.13/darwin.txt +++ b/requirements/static/pkg/py3.13/darwin.txt @@ -159,6 +159,8 @@ timelib==0.3.0 # -r requirements/static/pkg/darwin.in tornado==6.5.5 # via -r requirements/base.txt +truststore==0.10.4 + # via -r requirements/base.txt urllib3==2.7.0 # via # -r requirements/base.txt diff --git a/requirements/static/pkg/py3.13/freebsd.txt b/requirements/static/pkg/py3.13/freebsd.txt index 53d274d45e89..b390216d7eab 100644 --- a/requirements/static/pkg/py3.13/freebsd.txt +++ b/requirements/static/pkg/py3.13/freebsd.txt @@ -191,6 +191,8 @@ timelib==0.3.0 # -r requirements/static/pkg/freebsd.in tornado==6.5.5 # via -r requirements/base.txt +truststore==0.10.4 + # via -r requirements/base.txt urllib3==2.7.0 # via # -r requirements/base.txt diff --git a/requirements/static/pkg/py3.13/linux.txt b/requirements/static/pkg/py3.13/linux.txt index 018e503afd39..006ce92d2222 100644 --- a/requirements/static/pkg/py3.13/linux.txt +++ b/requirements/static/pkg/py3.13/linux.txt @@ -178,6 +178,8 @@ timelib==0.3.0 # -r requirements/static/pkg/linux.in tornado==6.5.5 # via -r requirements/base.txt +truststore==0.10.4 + # via -r requirements/base.txt urllib3==2.7.0 # via # -r requirements/base.txt diff --git a/requirements/static/pkg/py3.13/windows.txt b/requirements/static/pkg/py3.13/windows.txt index b8281ef232eb..257e3fc67aaa 100644 --- a/requirements/static/pkg/py3.13/windows.txt +++ b/requirements/static/pkg/py3.13/windows.txt @@ -181,6 +181,8 @@ timelib==0.3.0 # -r requirements/static/pkg/windows.in tornado==6.5.5 # via -r requirements/base.txt +truststore==0.10.4 + # via -r requirements/base.txt typer==0.24.1 # via typer-slim typer-slim==0.24.0 diff --git a/requirements/static/pkg/py3.14/darwin.txt b/requirements/static/pkg/py3.14/darwin.txt index a229102dfb18..e98208570ca1 100644 --- a/requirements/static/pkg/py3.14/darwin.txt +++ b/requirements/static/pkg/py3.14/darwin.txt @@ -173,6 +173,8 @@ timelib==0.3.0 # -r requirements/static/pkg/darwin.in tornado==6.5.5 # via -r requirements/base.txt +truststore==0.10.4 + # via -r requirements/base.txt typer==0.25.1 # via typer-slim typer-slim==0.24.0 diff --git a/requirements/static/pkg/py3.14/freebsd.txt b/requirements/static/pkg/py3.14/freebsd.txt index 308498a05f4a..0423c827d42a 100644 --- a/requirements/static/pkg/py3.14/freebsd.txt +++ b/requirements/static/pkg/py3.14/freebsd.txt @@ -209,6 +209,8 @@ timelib==0.3.0 # -r requirements/static/pkg/freebsd.in tornado==6.5.5 # via -r requirements/base.txt +truststore==0.10.4 + # via -r requirements/base.txt typer==0.25.1 # via typer-slim typer-slim==0.24.0 diff --git a/requirements/static/pkg/py3.14/linux.txt b/requirements/static/pkg/py3.14/linux.txt index 6a3d1562db89..7ce289412dbb 100644 --- a/requirements/static/pkg/py3.14/linux.txt +++ b/requirements/static/pkg/py3.14/linux.txt @@ -192,6 +192,8 @@ timelib==0.3.0 # -r requirements/static/pkg/linux.in tornado==6.5.5 # via -r requirements/base.txt +truststore==0.10.4 + # via -r requirements/base.txt typer==0.25.1 # via typer-slim typer-slim==0.24.0 diff --git a/requirements/static/pkg/py3.14/windows.txt b/requirements/static/pkg/py3.14/windows.txt index 545e84d3a20f..73dc79110640 100644 --- a/requirements/static/pkg/py3.14/windows.txt +++ b/requirements/static/pkg/py3.14/windows.txt @@ -185,6 +185,8 @@ timelib==0.3.0 # -r requirements/static/pkg/windows.in tornado==6.5.5 # via -r requirements/base.txt +truststore==0.10.4 + # via -r requirements/base.txt typer==0.25.1 # via typer-slim typer-slim==0.24.0 diff --git a/requirements/static/pkg/py3.9/freebsd.txt b/requirements/static/pkg/py3.9/freebsd.txt index 22f42012e72f..a4eadf09fd2f 100644 --- a/requirements/static/pkg/py3.9/freebsd.txt +++ b/requirements/static/pkg/py3.9/freebsd.txt @@ -216,6 +216,8 @@ timelib==0.3.0 # -r requirements/static/pkg/freebsd.in tornado==6.5.5 # via -r requirements/base.txt +truststore==0.10.4 ; python_full_version >= '3.10' + # via -r requirements/base.txt typing-extensions==4.15.0 ; python_full_version < '3.13' # via # aiosignal diff --git a/salt/cli/daemons.py b/salt/cli/daemons.py index b3f88909cc15..9e2124cb8f7b 100644 --- a/salt/cli/daemons.py +++ b/salt/cli/daemons.py @@ -9,6 +9,7 @@ import salt.utils.kinds as kinds from salt.exceptions import SaltClientError, SaltSystemExit, get_error_message from salt.utils import migrations +from salt.utils import ostruststore as _ostruststore from salt.utils.platform import is_junos from salt.utils.process import HAS_PSUTIL @@ -179,6 +180,7 @@ def prepare(self): super(YourSubClass, self).prepare() """ super().prepare() + _ostruststore.apply_if_enabled(self.config) try: self.verify_environment() @@ -261,6 +263,7 @@ def prepare(self): super(YourSubClass, self).prepare() """ super().prepare() + _ostruststore.apply_if_enabled(self.config) try: if self.config["verify_env"]: @@ -444,6 +447,7 @@ def prepare(self): super(YourSubClass, self).prepare() """ super().prepare() + _ostruststore.apply_if_enabled(self.config) ## allow for native minion if not is_junos(): @@ -586,6 +590,7 @@ def prepare(self): super(YourSubClass, self).prepare() """ super().prepare() + _ostruststore.apply_if_enabled(self.config) try: if self.config["verify_env"]: verify_env( diff --git a/salt/config/__init__.py b/salt/config/__init__.py index 8a3bbf686b8a..369ea11268c5 100644 --- a/salt/config/__init__.py +++ b/salt/config/__init__.py @@ -991,6 +991,8 @@ def _gather_buffer_space(): "ssl": (dict, bool, type(None)), # Disable redundant AES encryption when TLS is active with validated certificates "disable_aes_with_tls": bool, + # Use the native OS certificate store instead of the bundled certifi CA bundle + "use_os_truststore": bool, # Controls how a multi-function job returns its data. If this is False, # it will return its data using a dictionary with the function name as # the key. This is compatible with legacy systems. If this is True, it @@ -1420,6 +1422,7 @@ def _gather_buffer_space(): "global_state_conditions": None, "reactor_niceness": None, "fips_mode": False, + "use_os_truststore": False, "features": {}, "encryption_algorithm": "OAEP-SHA1", "signing_algorithm": "PKCS1v15-SHA1", @@ -1782,6 +1785,7 @@ def _gather_buffer_space(): "enable_ssh_minions": False, "netapi_allow_raw_shell": False, "fips_mode": False, + "use_os_truststore": False, "detect_remote_minions": False, "remote_minions_port": 22, "pass_variable_prefix": "", diff --git a/salt/grains/truststore.py b/salt/grains/truststore.py new file mode 100644 index 000000000000..510d6f3ae900 --- /dev/null +++ b/salt/grains/truststore.py @@ -0,0 +1,44 @@ +""" +Grain that reports which CA certificate store Salt is using for outbound +HTTPS/TLS connections. + +.. versionadded:: 3008.0 + +Possible values for the ``ca_truststore`` grain: + +``certifi`` + Default. Salt uses the ``certifi`` CA bundle (or a system bundle on + Linux when one is found at a well-known path). + +``os`` + Salt has successfully injected the native OS certificate store via + ``pip-system-certs`` (requires ``use_os_truststore: True`` in the minion + configuration and the ``pip-system-certs`` package installed). +""" + +import logging + +import salt.utils.ostruststore + +log = logging.getLogger(__name__) + +__virtualname__ = "truststore" + + +def __virtual__(): + return __virtualname__ + + +def ca_truststore(): + """ + Return the active CA trust store name as the ``ca_truststore`` grain. + + Example grain value:: + + ca_truststore: certifi + + or, when OS trust store is active:: + + ca_truststore: os + """ + return {"ca_truststore": salt.utils.ostruststore.active_store_name(__opts__)} diff --git a/salt/utils/http.py b/salt/utils/http.py index 46937bb825b5..4d2404a69784 100644 --- a/salt/utils/http.py +++ b/salt/utils/http.py @@ -788,6 +788,11 @@ def get_ca_bundle(opts=None): if opts_bundle is not None and os.path.exists(opts_bundle): return opts_bundle + if opts.get("use_os_truststore", False): + # The OS trust store was injected globally at daemon startup via + # pip-system-certs; no CA bundle file path is needed. + return None + file_roots = opts.get("file_roots", {"base": [salt.syspaths.SRV_ROOT_DIR]}) # Please do not change the order without good reason diff --git a/salt/utils/ostruststore.py b/salt/utils/ostruststore.py new file mode 100644 index 000000000000..8b61c9e25031 --- /dev/null +++ b/salt/utils/ostruststore.py @@ -0,0 +1,132 @@ +""" +Utility functions for OS-native TLS certificate store support. + +When ``use_os_truststore: True`` is set in the Salt master or minion +configuration, Salt calls :func:`apply_if_enabled` once at daemon startup. +That function uses the ``truststore`` library to monkey-patch Python's +``ssl.SSLContext`` so that every subsequent TLS connection verifies against +the native OS certificate store instead of the bundled ``certifi`` CA bundle: + +- **Windows** — Local Machine Certificate Store (accessed via CryptoAPI) +- **macOS** — Keychain +- **Linux** — ``/etc/ssl/certs`` or ``/etc/pki/tls`` + +The injection is process-global and one-shot; calling :func:`apply_if_enabled` +more than once is safe (idempotent). + +.. note:: + + This has no effect on Salt's master/minion PKI authentication layer + (``pki_dir``, AES session keys, minion key acceptance). It only affects + outbound HTTPS/TLS connections — HTTP runner, gitfs, fileserver backends, + cloud drivers, ``salt.utils.http.query``, etc. + +.. note:: + + ``truststore`` requires Python 3.10 or newer. On older Python the package + is not installed and ``use_os_truststore`` will log a warning and fall back + to the default ``certifi`` bundle. + +.. note:: + + On Windows the ``LocalSystem`` service account (the default for the + salt-master and salt-minion Windows services) only has access to the + **Local Machine** store, not the Current User store. Certificates + deployed via Group Policy to the Local Machine store are visible; + certificates installed only in a user's personal store are not. + +.. warning:: + + On Windows, certificate verification is performed via a CryptoAPI service + call rather than a simple file read. This may introduce a small amount of + extra latency on the first TLS connection made by a new process. On Linux + and macOS the performance impact is negligible. + +.. warning:: + + Do **not** install ``pip-system-certs`` into the Salt Python environment. + That package installs a ``.pth`` file that unconditionally activates the OS + trust store on every Python startup, before Salt reads its configuration. + This bypasses the ``use_os_truststore`` setting entirely. Use the + ``truststore`` package instead, which Salt controls explicitly. +""" + +import logging + +log = logging.getLogger(__name__) + +try: + import truststore as _truststore + + HAS_TRUSTSTORE = True +except ImportError: + _truststore = None # always defined so patch.object() works in tests + HAS_TRUSTSTORE = False + +# Module-level flag so we never inject more than once per process. +_injected = False + + +def apply_if_enabled(opts): + """ + Inject OS-native certificate store support when ``use_os_truststore`` is + enabled in *opts*. + + Safe to call multiple times — only the first call with a truthy + ``use_os_truststore`` triggers the injection. + + :param dict opts: Salt master or minion configuration dictionary. + """ + global _injected + + if not opts.get("use_os_truststore", False): + return + + if _injected: + return + + if not HAS_TRUSTSTORE: + log.warning( + "use_os_truststore is enabled but the 'truststore' package is not " + "installed. SSL connections will continue to use the bundled " + "certifi CA bundle. Install truststore (Python 3.10+) to enable " + "OS trust store support." + ) + return + + try: + _truststore.inject_into_ssl() + _injected = True + log.debug("OS trust store injected via truststore") + except Exception as exc: # pylint: disable=broad-exception-caught + log.error( + "Failed to inject OS trust store via truststore: %s", exc + ) # pragma: no cover + + +def is_injected(): + """ + Return ``True`` if the OS trust store has been successfully injected into + Python's SSL layer for this process. + + :rtype: bool + """ + return _injected + + +def active_store_name(opts): + """ + Return a short string identifying which CA store is active. + + Returns ``"os"`` when the OS trust store injection succeeded and + ``use_os_truststore`` is enabled in *opts*; otherwise returns + ``"certifi"``. + + This value is exposed as the ``ca_truststore`` grain. + + :param dict opts: Salt master or minion configuration dictionary. + :rtype: str + """ + if _injected and opts.get("use_os_truststore", False): + return "os" + return "certifi" diff --git a/tests/pytests/functional/utils/test_ostruststore.py b/tests/pytests/functional/utils/test_ostruststore.py new file mode 100644 index 000000000000..f6f2756324f3 --- /dev/null +++ b/tests/pytests/functional/utils/test_ostruststore.py @@ -0,0 +1,76 @@ +""" +Functional tests for salt.utils.ostruststore. + +These tests verify that, when truststore is actually available in the test +environment, the injection genuinely patches ssl.SSLContext. They are +skipped when the package is not installed. +""" + +import ssl + +import pytest + +import salt.utils.ostruststore as ostruststore +from tests.support.mock import patch + +pytestmark = [ + pytest.mark.windows_whitelisted, + pytest.mark.skipif( + not ostruststore.HAS_TRUSTSTORE, + reason="truststore is not installed; skipping OS truststore functional tests", + ), +] + + +@pytest.fixture(autouse=True) +def reset_injected_flag(): + """Reset injection state and ssl.SSLContext after each test.""" + original_ssl_context = ssl.SSLContext + with patch.object(ostruststore, "_injected", False): + yield + ssl.SSLContext = original_ssl_context + + +def test_injection_patches_ssl_context(): + """ + After apply_if_enabled with use_os_truststore=True, ssl.create_default_context() + should return a truststore-aware context. inject_into_ssl() replaces + ssl.SSLContext in-place, so we must capture the original class reference + before injection to have something to compare against. + """ + original_class = ssl.SSLContext # capture before injection replaces it + ostruststore.apply_if_enabled({"use_os_truststore": True}) + assert ostruststore.is_injected() is True + ctx = ssl.create_default_context() + assert ( + type(ctx) is not original_class + ), "Expected a truststore-patched context, got plain ssl.SSLContext" + + +def test_injection_is_idempotent(): + """Calling apply_if_enabled twice does not raise and only patches once.""" + ostruststore.apply_if_enabled({"use_os_truststore": True}) + context_class_after_first = type(ssl.create_default_context()) + ostruststore.apply_if_enabled({"use_os_truststore": True}) + context_class_after_second = type(ssl.create_default_context()) + assert context_class_after_first is context_class_after_second + + +def test_no_injection_when_disabled(): + """When use_os_truststore is False, ssl.SSLContext is not patched.""" + original = ssl.SSLContext + ostruststore.apply_if_enabled({"use_os_truststore": False}) + assert ssl.SSLContext is original + assert ostruststore.is_injected() is False + + +def test_active_store_name_os_after_injection(): + """active_store_name returns 'os' after a successful injection.""" + ostruststore.apply_if_enabled({"use_os_truststore": True}) + assert ostruststore.active_store_name({"use_os_truststore": True}) == "os" + + +def test_active_store_name_certifi_when_disabled(): + """active_store_name returns 'certifi' when injection was not requested.""" + ostruststore.apply_if_enabled({"use_os_truststore": False}) + assert ostruststore.active_store_name({"use_os_truststore": False}) == "certifi" diff --git a/tests/pytests/unit/grains/test_truststore.py b/tests/pytests/unit/grains/test_truststore.py new file mode 100644 index 000000000000..25cb4d21a233 --- /dev/null +++ b/tests/pytests/unit/grains/test_truststore.py @@ -0,0 +1,55 @@ +""" +Unit tests for salt.grains.truststore. +""" + +import pytest + +import salt.grains.truststore as truststore_grains +import salt.utils.ostruststore as ostruststore +from tests.support.mock import patch + + +@pytest.fixture(autouse=True) +def reset_injected_flag(): + with patch.object(ostruststore, "_injected", False): + yield + + +def test_grain_certifi_default(): + """ca_truststore grain is 'certifi' when use_os_truststore is False.""" + with patch.object( + truststore_grains, "__opts__", {"use_os_truststore": False}, create=True + ): + result = truststore_grains.ca_truststore() + assert result == {"ca_truststore": "certifi"} + + +def test_grain_certifi_when_opts_empty(): + """ca_truststore grain is 'certifi' when opts are empty.""" + with patch.object(truststore_grains, "__opts__", {}, create=True): + result = truststore_grains.ca_truststore() + assert result == {"ca_truststore": "certifi"} + + +def test_grain_os_when_injected_and_enabled(): + """ca_truststore grain is 'os' when injection succeeded and option is True.""" + with patch.object(ostruststore, "_injected", True): + with patch.object( + truststore_grains, "__opts__", {"use_os_truststore": True}, create=True + ): + result = truststore_grains.ca_truststore() + assert result == {"ca_truststore": "os"} + + +def test_grain_certifi_injected_but_option_off(): + """ca_truststore grain is 'certifi' even if injected when option is False.""" + with patch.object(ostruststore, "_injected", True): + with patch.object( + truststore_grains, "__opts__", {"use_os_truststore": False}, create=True + ): + result = truststore_grains.ca_truststore() + assert result == {"ca_truststore": "certifi"} + + +def test_virtual_returns_name(): + assert truststore_grains.__virtual__() == "truststore" diff --git a/tests/pytests/unit/utils/test_http.py b/tests/pytests/unit/utils/test_http.py index 531e59779678..fced1f82d23d 100644 --- a/tests/pytests/unit/utils/test_http.py +++ b/tests/pytests/unit/utils/test_http.py @@ -343,6 +343,37 @@ def test_backends_decode_body_true(httpserver, backend): assert isinstance(body, str) +def test_get_ca_bundle_os_truststore_skips_bundle_search(): + """ + When use_os_truststore is True and no explicit ca_bundle is configured, + get_ca_bundle should return None (OS trust store handles verification). + """ + opts = {"use_os_truststore": True} + result = http.get_ca_bundle(opts) + assert result is None + + +def test_get_ca_bundle_explicit_ca_bundle_wins_over_os_truststore(): + """ + An explicit ca_bundle path always takes precedence over use_os_truststore. + """ + opts = {"use_os_truststore": True, "ca_bundle": "/path/to/bundle.pem"} + with patch("os.path.exists", MagicMock(return_value=True)): + result = http.get_ca_bundle(opts) + assert result == "/path/to/bundle.pem" + + +def test_get_ca_bundle_os_truststore_false_falls_through(): + """ + When use_os_truststore is False, normal CA bundle discovery proceeds. + """ + opts = {"use_os_truststore": False} + with patch("os.path.exists", MagicMock(return_value=False)): + with patch("salt.utils.platform.is_windows", MagicMock(return_value=False)): + result = http.get_ca_bundle(opts) + assert result is None + + def test_requests_post_content_type(httpserver): url = httpserver.url_for("/post-content-type") data = urllib.parse.urlencode({"payload": "test"}) diff --git a/tests/pytests/unit/utils/test_ostruststore.py b/tests/pytests/unit/utils/test_ostruststore.py new file mode 100644 index 000000000000..9808804c1280 --- /dev/null +++ b/tests/pytests/unit/utils/test_ostruststore.py @@ -0,0 +1,113 @@ +""" +Unit tests for salt.utils.ostruststore. +""" + +import pytest + +import salt.utils.ostruststore as ostruststore +from tests.support.mock import MagicMock, patch + + +@pytest.fixture(autouse=True) +def reset_injected_flag(): + """Reset the module-level _injected flag before and after every test.""" + with patch.object(ostruststore, "_injected", False): + yield + + +# --------------------------------------------------------------------------- +# apply_if_enabled +# --------------------------------------------------------------------------- + + +def test_apply_if_enabled_disabled_by_default(): + """When use_os_truststore is absent, injection is skipped.""" + ostruststore.apply_if_enabled({}) + assert ostruststore.is_injected() is False + + +def test_apply_if_enabled_explicit_false(): + """When use_os_truststore is False, injection is skipped.""" + ostruststore.apply_if_enabled({"use_os_truststore": False}) + assert ostruststore.is_injected() is False + + +def test_apply_if_enabled_missing_package(caplog): + """ + When use_os_truststore is True but truststore is not installed, + a warning is logged and injection is skipped. + """ + import logging + + with patch.object(ostruststore, "HAS_TRUSTSTORE", False): + with caplog.at_level(logging.WARNING, logger="salt.utils.ostruststore"): + ostruststore.apply_if_enabled({"use_os_truststore": True}) + + assert ostruststore.is_injected() is False + assert "truststore" in caplog.text + + +def test_apply_if_enabled_success(): + """When the package is available, inject_into_ssl() is called once.""" + mock_truststore = MagicMock() + + with patch.object(ostruststore, "HAS_TRUSTSTORE", True): + with patch.object(ostruststore, "_truststore", mock_truststore): + ostruststore.apply_if_enabled({"use_os_truststore": True}) + + mock_truststore.inject_into_ssl.assert_called_once() + assert ostruststore.is_injected() is True + + +def test_apply_if_enabled_idempotent(): + """Calling apply_if_enabled twice only injects once.""" + mock_truststore = MagicMock() + + with patch.object(ostruststore, "HAS_TRUSTSTORE", True): + with patch.object(ostruststore, "_truststore", mock_truststore): + ostruststore.apply_if_enabled({"use_os_truststore": True}) + ostruststore.apply_if_enabled({"use_os_truststore": True}) + + mock_truststore.inject_into_ssl.assert_called_once() + + +# --------------------------------------------------------------------------- +# is_injected +# --------------------------------------------------------------------------- + + +def test_is_injected_default_false(): + assert ostruststore.is_injected() is False + + +def test_is_injected_true_after_apply(): + mock_truststore = MagicMock() + + with patch.object(ostruststore, "HAS_TRUSTSTORE", True): + with patch.object(ostruststore, "_truststore", mock_truststore): + ostruststore.apply_if_enabled({"use_os_truststore": True}) + + assert ostruststore.is_injected() is True + + +# --------------------------------------------------------------------------- +# active_store_name +# --------------------------------------------------------------------------- + + +def test_active_store_name_certifi_when_not_injected(): + assert ostruststore.active_store_name({"use_os_truststore": True}) == "certifi" + + +def test_active_store_name_certifi_when_disabled(): + with patch.object(ostruststore, "_injected", True): + assert ostruststore.active_store_name({"use_os_truststore": False}) == "certifi" + + +def test_active_store_name_os_when_injected_and_enabled(): + with patch.object(ostruststore, "_injected", True): + assert ostruststore.active_store_name({"use_os_truststore": True}) == "os" + + +def test_active_store_name_certifi_default_opts(): + assert ostruststore.active_store_name({}) == "certifi"