Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
c37efd5
PYTHON-5805 Split TLS wrapping out of configured socket helpers
blink1073 Aug 17, 2026
967f17a
PYTHON-5805 Add KMSConnectContext and AutoEncryptionOpts.kms_connect_…
blink1073 Aug 17, 2026
38ecefc
PYTHON-5805 Route KMS connections through kms_connect_callback
blink1073 Aug 17, 2026
8c24872
PYTHON-5805 Add kms_connect_callback to ClientEncryption
blink1073 Aug 17, 2026
84c76a2
PYTHON-5805 Fix sync ClientEncryption docstring to say 'regular funct…
blink1073 Aug 18, 2026
0b1f785
PYTHON-5805 Add synchro tripwire for kms_connect_callback docstring m…
blink1073 Aug 18, 2026
6f2b0e1
PYTHON-5805 Add prose tests for KMS connect callback
blink1073 Aug 18, 2026
516b267
PYTHON-5805 Fix KMSConnectContext.timeout docstring and document case…
blink1073 Aug 18, 2026
640b991
PYTHON-5805 Add changelog entry for kms_connect_callback
blink1073 Aug 18, 2026
9c1c544
PYTHON-5805 Guard async callback contract and fix proxy example docs
blink1073 Aug 18, 2026
93c1445
PYTHON-5805 Skip prose case 5 as a known CSOT spec discrepancy
blink1073 Aug 18, 2026
ac823c8
PYTHON-5805 Tighten prose in docs, changelog, and test comments
blink1073 Aug 19, 2026
b3d0c29
PYTHON-5805 Use ConfigurationError and drop the synchro docstring tri…
blink1073 Aug 20, 2026
defb5c5
PYTHON-5805 Normalize the callback socket's blocking mode
blink1073 Aug 20, 2026
fba651b
PYTHON-5805 Name the asyncio transport case in the callback error
blink1073 Aug 20, 2026
4c20867
PYTHON-5805 Tighten prose further in docstrings, comments, and errors
blink1073 Aug 20, 2026
b9e0666
PYTHON-5805 Say module rather than flavor in the _IS_SYNC comment
blink1073 Aug 20, 2026
b1beab6
PYTHON-5805 Add HTTPProxyKMSConnect so callers need not write CONNECT
blink1073 Aug 20, 2026
6d4d6d3
PYTHON-5805 Point the changelog at the proxy helper
blink1073 Aug 20, 2026
8dc628d
PYTHON-5805 Use run_in_executor to match the library's existing idiom
blink1073 Aug 20, 2026
2ce4298
PYTHON-5805 Make the CSOT deviation note a full sentence
blink1073 Aug 20, 2026
d9d5a71
PYTHON-5805 Record why _bridge uses threads rather than tasks
blink1073 Aug 20, 2026
d38bd00
PYTHON-5805 Name the async class in the threads-over-tasks note
blink1073 Aug 20, 2026
5446bfc
PYTHON-5805 Cut prose to the house budgets
blink1073 Aug 20, 2026
8d32dbc
PYTHON-5805 Point the callback docs at the proxy helper
blink1073 Aug 20, 2026
a709ce1
PYTHON-5805 Hoist test imports to module scope
blink1073 Aug 20, 2026
082d5ed
PYTHON-5805 Drop underscores from the proxy test constants
blink1073 Aug 20, 2026
1dce305
PYTHON-5805 Collapse the remaining comments to one line each
blink1073 Aug 21, 2026
bdeb7f9
Merge remote-tracking branch 'upstream/main' into PYTHON-5805
blink1073 Aug 21, 2026
869e91f
PYTHON-5805 Cover the proxy helper's tunnel and relay
blink1073 Aug 21, 2026
50fca5c
PYTHON-5805 Stop _tunnel consuming tunnelled bytes
blink1073 Aug 21, 2026
7d9158a
PYTHON-5805 Address review findings on the proxy helper
blink1073 Aug 21, 2026
24f1fd8
PYTHON-5805 Address the second review pass
blink1073 Aug 21, 2026
26e3cb4
PYTHON-5805 Close leaks found in the third review pass
blink1073 Aug 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions doc/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,17 @@ PyMongo 4.18 brings a number of changes including:
:meth:`~pymongo.synchronous.database.Database.aggregate`, and
:meth:`~pymongo.asynchronous.collection.AsyncCollection.list_search_indexes`
and :meth:`~pymongo.synchronous.collection.Collection.list_search_indexes`.
- Added support for routing Key Management Service (KMS) requests for
Client-Side Field Level Encryption and Queryable Encryption through an HTTP
proxy, using the new ``kms_connect_callback`` option on
:class:`~pymongo.encryption_options.AutoEncryptionOpts`,
:class:`~pymongo.encryption.ClientEncryption`, and
:class:`~pymongo.asynchronous.encryption.AsyncClientEncryption`. The callback
opens the connection and the driver performs the KMS TLS handshake over it, so
verification still targets the KMS host rather than the proxy. For an ordinary
HTTP proxy, pass :class:`~pymongo.encryption_options.HTTPProxyKMSConnect` or
:class:`~pymongo.encryption_options.AsyncHTTPProxyKMSConnect` instead of
writing a callback.

Changes in Version 4.17.0 (2026/04/20)
--------------------------------------
Expand Down
102 changes: 92 additions & 10 deletions pymongo/asynchronous/encryption.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@
import asyncio
import contextlib
import enum
import inspect
import socket
import ssl
import time as time # noqa: PLC0414 # needed in sync version
import uuid
import weakref
Expand Down Expand Up @@ -63,7 +65,9 @@
from pymongo.common import CONNECT_TIMEOUT
from pymongo.daemon import _spawn_daemon
from pymongo.encryption_options import (
AsyncKMSConnectCallback,
AutoEncryptionOpts,
KMSConnectContext,
RangeOpts,
TextOpts,
check_min_pymongocrypt,
Expand All @@ -82,6 +86,7 @@
from pymongo.pool_options import PoolOptions
from pymongo.pool_shared import (
_async_configured_socket,
_async_wrap_socket_tls,
_raise_connection_failure,
)
from pymongo.read_concern import ReadConcern
Expand Down Expand Up @@ -112,9 +117,65 @@
_KEY_VAULT_OPTS = CodecOptions(document_class=RawBSONDocument)


async def _connect_kms(address: _Address, opts: PoolOptions) -> Union[socket.socket, _sslConn]:
def _close_rejected_kms_socket(obj: Any) -> None:
"""Close a rejected kms_connect_callback return value, if it can be closed.

The caller may have handed us a live socket, and nothing else will close it:
_connect_kms raises before its result reaches the caller's ``finally``. The
value can be anything a user returned, so closing is strictly best effort.
"""
close = getattr(obj, "close", None)
if callable(close):
with contextlib.suppress(Exception):
close()


async def _connect_kms(
address: _Address,
opts: PoolOptions,
kms_connect_callback: Optional[AsyncKMSConnectCallback],
timeout: float,
) -> Union[socket.socket, _sslConn]:
if kms_connect_callback is None:
try:
return await _async_configured_socket(address, opts)
except Exception as exc:
_raise_connection_failure(address, exc, timeout_details=_get_timeout_details(opts))

# TLS targets address, not the peer, so verification follows the KMS host.
result = kms_connect_callback(
KMSConnectContext(host=address[0], port=cast(int, address[1]), timeout=timeout)
)
# The synchronous module takes a regular function and awaits nothing.
if not _IS_SYNC and not inspect.isawaitable(result):
_close_rejected_kms_socket(result)
raise ConfigurationError(
"kms_connect_callback must be a coroutine function for the async "
f"API, but returned {type(result)}."
)
sock = await result
if not isinstance(sock, socket.socket) or isinstance(sock, ssl.SSLSocket):
Comment thread
blink1073 marked this conversation as resolved.
_close_rejected_kms_socket(sock)
raise ConfigurationError(
"kms_connect_callback must return a connected, unwrapped "
f"socket.socket, not {type(sock)}; consider HTTPProxyKMSConnect."
Comment thread
blink1073 marked this conversation as resolved.
)
# wrap_socket refuses a non-blocking socket, so normalize the mode here.
try:
return await _async_configured_socket(address, opts)
sock.getpeername()
except OSError:
_close_rejected_kms_socket(sock)
raise ConfigurationError(
"kms_connect_callback must return an already connected socket."
) from None
if sock.getsockopt(socket.SOL_SOCKET, socket.SO_TYPE) != socket.SOCK_STREAM:
_close_rejected_kms_socket(sock)
raise ConfigurationError(
"kms_connect_callback must return a stream socket, not a datagram one."
)
sock.settimeout(opts.socket_timeout)
try:
return await _async_wrap_socket_tls(sock, address, opts)
except Exception as exc:
_raise_connection_failure(address, exc, timeout_details=_get_timeout_details(opts))

Expand Down Expand Up @@ -184,20 +245,26 @@ async def kms_request(self, kms_context: MongoCryptKmsContext) -> None:
False, # disable_ocsp_endpoint_check
_IS_SYNC,
)
# CSOT: set timeout for socket creation.
address = parse_host(endpoint, _HTTPS_PORT)
sleep_u = kms_context.usleep
if sleep_u:
sleep_sec = float(sleep_u) / 1e6
await asyncio.sleep(sleep_sec)
# CSOT: set timeout for socket creation. After the retry backoff above,
# so the budget reflects what the sleep consumed.
connect_timeout = max(_csot.clamp_remaining(_KMS_CONNECT_TIMEOUT), 0.001)
opts = PoolOptions(
connect_timeout=connect_timeout,
socket_timeout=connect_timeout,
ssl_context=ctx,
)
address = parse_host(endpoint, _HTTPS_PORT)
sleep_u = kms_context.usleep
if sleep_u:
sleep_sec = float(sleep_u) / 1e6
await asyncio.sleep(sleep_sec)
try:
conn = await _connect_kms(address, opts)
conn = await _connect_kms(
address,
opts,
self.opts._kms_connect_callback,
connect_timeout,
Comment thread
blink1073 marked this conversation as resolved.
)
try:
await async_socket_sendall(conn, message)
while kms_context.bytes_needed > 0:
Expand Down Expand Up @@ -233,6 +300,8 @@ async def kms_request(self, kms_context: MongoCryptKmsContext) -> None:
conn.close()
except MongoCryptError:
raise # Propagate MongoCryptError errors directly.
except ConfigurationError:
raise # A callback contract violation is not transient.
Comment thread
blink1073 marked this conversation as resolved.
except Exception as exc:
remaining = _csot.remaining()
if isinstance(exc, NetworkTimeout) or (remaining is not None and remaining <= 0):
Expand Down Expand Up @@ -596,6 +665,7 @@ def __init__(
codec_options: CodecOptions[_DocumentTypeArg],
kms_tls_options: Optional[Mapping[str, Any]] = None,
key_expiration_ms: Optional[int] = None,
kms_connect_callback: Optional[AsyncKMSConnectCallback] = None,
) -> None:
"""Explicit client-side field level encryption.

Expand Down Expand Up @@ -665,7 +735,18 @@ def __init__(
:param key_expiration_ms: The cache expiration time for data encryption keys.
Defaults to ``None`` which defers to libmongocrypt's default which is currently 60000.
Set to 0 to disable key expiration.

:param kms_connect_callback: A callable that opens the connection to a
KMS host, used to route KMS requests through an HTTP proxy. It
receives a :class:`~pymongo.encryption_options.KMSConnectContext`
and returns a connected, unwrapped :class:`socket.socket`; the
driver then performs the KMS TLS handshake over it. For an ordinary
HTTP proxy, pass
:class:`~pymongo.encryption_options.AsyncHTTPProxyKMSConnect`.
Defaults to ``None``, meaning the driver connects to KMS hosts
directly.

.. versionchanged:: 4.18
Added the `kms_connect_callback` parameter.
.. versionchanged:: 4.12
Added the `key_expiration_ms` parameter.
.. versionchanged:: 4.0
Expand Down Expand Up @@ -709,6 +790,7 @@ def __init__(
key_vault_namespace,
kms_tls_options=kms_tls_options,
key_expiration_ms=key_expiration_ms,
kms_connect_callback=kms_connect_callback,
)
self._kms_ssl_contexts = _parse_kms_tls_options(opts._kms_tls_options, _IS_SYNC)
self._io_callbacks: Optional[_EncryptionIO] = _EncryptionIO(
Expand Down
Loading
Loading