Skip to content

Commit f4962cc

Browse files
committed
Optional truststore support
This adds a --use-feature=truststore flag that, when specified on Python 3.10+ with truststore installed, switches pip to use truststore to provide HTTPS certificate validation, instead of certifi. This allows pip to verify certificates against custom certificates in the system store. truststore is deliberately NOT vendored because it is expected the library to be under active development in the short term, and this prevents users having to wait for a pip release to get potentially vital bug fixes needed to be made in truststore. Supplying the use-feature flag without installing truststore beforehand, or on Python versions prior to 3.10, results in a command error.
1 parent f51d471 commit f4962cc

File tree

4 files changed

+119
-11
lines changed

4 files changed

+119
-11
lines changed

news/11082.feature.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Add support to use `truststore <https://pypi.org/project/truststore/>`_ as an alternative SSL certificate verification backend. The backend can be enabled on Python 3.10 and later by installing ``truststore`` into the environment, and adding the ``--use-feature=truststore`` flag to various pip commands.
2+
3+
``truststore`` differs from the current default verification backend (provided by ``certifi``) in it uses the operating system’s trust store, which can be better controlled and augmented to better support non-standard certificates. Depending on feedback, pip may switch to this as the default certificate verification backend in the future.

src/pip/_internal/cli/cmdoptions.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -991,7 +991,7 @@ def check_list_path_option(options: Values) -> None:
991991
metavar="feature",
992992
action="append",
993993
default=[],
994-
choices=["2020-resolver", "fast-deps"],
994+
choices=["2020-resolver", "fast-deps", "truststore"],
995995
help="Enable new functionality, that may be backward incompatible.",
996996
)
997997

src/pip/_internal/cli/req_command.py

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import sys
1111
from functools import partial
1212
from optparse import Values
13-
from typing import Any, List, Optional, Tuple
13+
from typing import TYPE_CHECKING, Any, List, Optional, Tuple
1414

1515
from pip._internal.cache import WheelCache
1616
from pip._internal.cli import cmdoptions
@@ -42,9 +42,33 @@
4242
)
4343
from pip._internal.utils.virtualenv import running_under_virtualenv
4444

45+
if TYPE_CHECKING:
46+
from ssl import SSLContext
47+
4548
logger = logging.getLogger(__name__)
4649

4750

51+
def _create_truststore_ssl_context() -> Optional["SSLContext"]:
52+
if sys.version_info < (3, 10):
53+
raise CommandError("The truststore feature is only available for Python 3.10+")
54+
55+
try:
56+
import ssl
57+
except ImportError:
58+
logger.warning("Disabling truststore since ssl support is missing")
59+
return None
60+
61+
try:
62+
import truststore
63+
except ImportError:
64+
raise CommandError(
65+
"To use the truststore feature, 'truststore' must be installed into "
66+
"pip's current environment."
67+
)
68+
69+
return truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
70+
71+
4872
class SessionCommandMixin(CommandContextMixIn):
4973

5074
"""
@@ -84,15 +108,27 @@ def _build_session(
84108
options: Values,
85109
retries: Optional[int] = None,
86110
timeout: Optional[int] = None,
111+
fallback_to_certifi: bool = False,
87112
) -> PipSession:
88-
assert not options.cache_dir or os.path.isabs(options.cache_dir)
113+
cache_dir = options.cache_dir
114+
assert not cache_dir or os.path.isabs(cache_dir)
115+
116+
if "truststore" in options.features_enabled:
117+
try:
118+
ssl_context = _create_truststore_ssl_context()
119+
except Exception:
120+
if not fallback_to_certifi:
121+
raise
122+
ssl_context = None
123+
else:
124+
ssl_context = None
125+
89126
session = PipSession(
90-
cache=(
91-
os.path.join(options.cache_dir, "http") if options.cache_dir else None
92-
),
127+
cache=os.path.join(cache_dir, "http") if cache_dir else None,
93128
retries=retries if retries is not None else options.retries,
94129
trusted_hosts=options.trusted_hosts,
95130
index_urls=self._get_index_urls(options),
131+
ssl_context=ssl_context,
96132
)
97133

98134
# Handle custom ca-bundles from the user
@@ -142,7 +178,14 @@ def handle_pip_version_check(self, options: Values) -> None:
142178

143179
# Otherwise, check if we're using the latest version of pip available.
144180
session = self._build_session(
145-
options, retries=0, timeout=min(5, options.timeout)
181+
options,
182+
retries=0,
183+
timeout=min(5, options.timeout),
184+
# This is set to ensure the function does not fail when truststore is
185+
# specified in use-feature but cannot be loaded. This usually raises a
186+
# CommandError and shows a nice user-facing error, but this function is not
187+
# called in that try-except block.
188+
fallback_to_certifi=True,
146189
)
147190
with session:
148191
pip_self_version_check(session, options)

src/pip/_internal/network/session.py

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,23 @@
1515
import sys
1616
import urllib.parse
1717
import warnings
18-
from typing import Any, Dict, Generator, List, Mapping, Optional, Sequence, Tuple, Union
18+
from typing import (
19+
TYPE_CHECKING,
20+
Any,
21+
Dict,
22+
Generator,
23+
List,
24+
Mapping,
25+
Optional,
26+
Sequence,
27+
Tuple,
28+
Union,
29+
)
1930

2031
from pip._vendor import requests, urllib3
21-
from pip._vendor.cachecontrol import CacheControlAdapter
22-
from pip._vendor.requests.adapters import BaseAdapter, HTTPAdapter
32+
from pip._vendor.cachecontrol import CacheControlAdapter as _BaseCacheControlAdapter
33+
from pip._vendor.requests.adapters import DEFAULT_POOLBLOCK, BaseAdapter
34+
from pip._vendor.requests.adapters import HTTPAdapter as _BaseHTTPAdapter
2335
from pip._vendor.requests.models import PreparedRequest, Response
2436
from pip._vendor.requests.structures import CaseInsensitiveDict
2537
from pip._vendor.urllib3.connectionpool import ConnectionPool
@@ -37,6 +49,12 @@
3749
from pip._internal.utils.misc import build_url_from_netloc, parse_netloc
3850
from pip._internal.utils.urls import url_to_path
3951

52+
if TYPE_CHECKING:
53+
from ssl import SSLContext
54+
55+
from pip._vendor.urllib3.poolmanager import PoolManager
56+
57+
4058
logger = logging.getLogger(__name__)
4159

4260
SecureOrigin = Tuple[str, str, Optional[Union[int, str]]]
@@ -233,6 +251,48 @@ def close(self) -> None:
233251
pass
234252

235253

254+
class _SSLContextAdapterMixin:
255+
"""Mixin to add the ``ssl_context`` contructor argument to HTTP adapters.
256+
257+
The additional argument is forwarded directly to the pool manager. This allows us
258+
to dynamically decide what SSL store to use at runtime, which is used to implement
259+
the optional ``truststore`` backend.
260+
"""
261+
262+
def __init__(
263+
self,
264+
*,
265+
ssl_context: Optional["SSLContext"] = None,
266+
**kwargs: Any,
267+
) -> None:
268+
self._ssl_context = ssl_context
269+
super().__init__(**kwargs)
270+
271+
def init_poolmanager(
272+
self,
273+
connections: int,
274+
maxsize: int,
275+
block: bool = DEFAULT_POOLBLOCK,
276+
**pool_kwargs: Any,
277+
) -> "PoolManager":
278+
if self._ssl_context is not None:
279+
pool_kwargs.setdefault("ssl_context", self._ssl_context)
280+
return super().init_poolmanager( # type: ignore[misc]
281+
connections=connections,
282+
maxsize=maxsize,
283+
block=block,
284+
**pool_kwargs,
285+
)
286+
287+
288+
class HTTPAdapter(_SSLContextAdapterMixin, _BaseHTTPAdapter):
289+
pass
290+
291+
292+
class CacheControlAdapter(_SSLContextAdapterMixin, _BaseCacheControlAdapter):
293+
pass
294+
295+
236296
class InsecureHTTPAdapter(HTTPAdapter):
237297
def cert_verify(
238298
self,
@@ -266,6 +326,7 @@ def __init__(
266326
cache: Optional[str] = None,
267327
trusted_hosts: Sequence[str] = (),
268328
index_urls: Optional[List[str]] = None,
329+
ssl_context: Optional["SSLContext"] = None,
269330
**kwargs: Any,
270331
) -> None:
271332
"""
@@ -318,13 +379,14 @@ def __init__(
318379
secure_adapter = CacheControlAdapter(
319380
cache=SafeFileCache(cache),
320381
max_retries=retries,
382+
ssl_context=ssl_context,
321383
)
322384
self._trusted_host_adapter = InsecureCacheControlAdapter(
323385
cache=SafeFileCache(cache),
324386
max_retries=retries,
325387
)
326388
else:
327-
secure_adapter = HTTPAdapter(max_retries=retries)
389+
secure_adapter = HTTPAdapter(max_retries=retries, ssl_context=ssl_context)
328390
self._trusted_host_adapter = insecure_adapter
329391

330392
self.mount("https://", secure_adapter)

0 commit comments

Comments
 (0)