Skip to content

RUBY-3813 Support KMS connect callback for HTTP proxies - #3119

Open
jamis wants to merge 23 commits into
mongodb:masterfrom
jamis:3813-kms-connect-callback
Open

jamis wants to merge 23 commits into
mongodb:masterfrom
jamis:3813-kms-connect-callback

Conversation

@jamis

@jamis jamis commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Adds a :kms_connect_callback option to auto-encryption and Mongo::ClientEncryption, so CSFLE/QE users behind an HTTP proxy can route KMS requests through it. The callback gets the KMS host, port, and the remaining timeout, and hands back a socket already connected to that host. The driver wraps that in TLS to the KMS host itself, so SNI and certificate verification still target the real endpoint rather than the proxy.

Socket::SSL can now handshake over a socket it didn't create, and the CSOT timeout now reaches the KMS socket. That last part also exposed a milliseconds-vs-seconds bug that would have made CSOT KMS I/O effectively unbounded, so that's fixed here too.

The six prose tests from the spec need AWS credentials, so they only really run in Evergreen under the test-fle task; locally they skip cleanly. Note: KMS socket operations are now bounded by the remaining operation budget instead of a flat 10 seconds.

jamis added 10 commits October 26, 2026 13:10
…result

feed_kms passed timeout_ms straight to Timeout.timeout, which takes
seconds, so a CSOT budget of 30000 bounded socket reads and writes to
30000 seconds. Convert the budget to seconds and use the CSOT timeout
error class when a budget is in effect.

Also reject a kms_connect_callback that returns nil or a non-IO value
with a clear KmsError, instead of silently dialing the KMS host directly.
build_tcp_socket ran outside the rescue in SSL#initialize, so a failure
in setsockopt or set_socket_options leaked the freshly created socket.
It now closes the socket before re-raising.

Likewise, when create_context raised, a caller-provided
:connected_socket was never closed because @tcp_socket had not been
assigned yet. Close it on that failure path.
connect_callback always opened a plain TCPSocket and wrote a cleartext
CONNECT, so against a proxy started in TLS mode (--ca_file/--cert_file) the
TLS handshake failed with ECONNRESET and spec case 2 could never pass.

Add a tls: keyword to connect_callback. When true, the CONNECT handshake runs
over a TLS connection to the proxy, and the callback returns one end of a
socketpair bridged to the proxy by a background thread. The bridge is needed
because the driver wraps the returned socket in TLS to the KMS host, and Ruby's
OpenSSL::SSL::SSLSocket cannot wrap another SSLSocket.

Also pass extra_options from the auto-encryption client (matching every other
FLE integration spec) and record the deliberate timeout_ms deviation in case 5.
The option docs described the callback's contract but gave no usage example and
did not state the return type precisely. The returned object must be fd-backed
(a real IO or Socket) because the driver wraps it in TLS to the KMS host and
Ruby's OpenSSL requires an fd-backed object; a TLS-to-proxy connection
therefore needs a socketpair bridge.

Add a usage example to ClientEncryption#initialize and reference it from the
Client and AutoEncrypter option docs.
@jamis
jamis requested a review from a team as a code owner October 26, 2026 21:52
@jamis
jamis requested a review from comandeo-mongo October 26, 2026 21:52
@jamis
jamis marked this pull request as draft October 26, 2026 22:35

@comandeo-mongo comandeo-mongo left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Many tests are red

jamis added 2 commits October 27, 2026 14:56
The KMS HTTP proxy specs expect createDataKey to succeed against the real
AWS KMS endpoint, but they passed kms_tls_options with ssl_ca_cert set to
the test CA. The driver then verified the real KMS certificate against a
CA that cannot have issued it, and every case failed with "certificate
verify failed (unable to get local issuer certificate)".

TLS to KMS is negotiated end-to-end with the real KMS host, so drop
kms_tls_options and let verification use the system trust store, matching
custom_endpoint_spec and on_demand_aws_credentials_spec. The test CA only
applies to the local mock KMS servers, which cannot serve these cases.
…llback spec

The new kms_connect_callback example built an AutoEncrypter without
extra_options, so its Handle fell back to libmongocrypt's $SYSTEM
crypt_shared search. Every other FLE Handle in the process loads
crypt_shared via the explicit MONGO_RUBY_DRIVER_CRYPT_SHARED_LIB_PATH
override, so the $SYSTEM search could not resolve to that same
resident library and mongocrypt_init raised "An existing crypt_shared
library is loaded by the application".

Pass extra_options like the rest of the file so all Handles use the
same load mechanism.
@jamis
jamis marked this pull request as ready for review October 28, 2026 15:04
@jamis
jamis requested a balanced review from Copilot October 28, 2026 16:00

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Critical timeout-handling gaps across TLS setup, I/O, and retries allow KMS requests to exceed operation deadlines or hang indefinitely.

7 open issues
What changed in this PR

Adds kms_connect_callback to the MongoDB Ruby driver so automatic and explicit encryption can route KMS connections through HTTP proxies while retaining TLS verification against the KMS endpoint.

Changes:

  • Exposes and forwards the callback through both encryption APIs.
  • Adds TLS support for pre-connected sockets and propagates operation timeouts to KMS I/O, correcting milliseconds-to-seconds conversion.
  • Adds proxy helpers, integration coverage, socket cleanup tests, and usage documentation.

Suggested label: feature.

File Description
spec/​support/​kms_http_proxy.rb Adds proxy lifecycle, CONNECT, and TLS bridge helpers.
spec/​spec_helper.rb Loads the proxy helper.
spec/​mongo/​socket/​ssl_spec.rb Tests pre-connected sockets and failure cleanup.
spec/​mongo/​crypt/​encryption_io_spec.rb Tests callbacks, validation, and KMS I/O timeouts.
spec/​mongo/​crypt/​auto_encrypter_spec.rb Tests automatic-encryption callback forwarding.
spec/​mongo/​client_encryption_spec.rb Tests explicit-encryption callback forwarding.
spec/​integration/​client_side_encryption/​kms_http_proxy_spec.rb Adds AWS-backed proxy integration scenarios.
lib/​mongo/​socket/​ssl.rb Supports TLS over supplied sockets and improves cleanup.
lib/​mongo/​crypt/​explicit_encrypter.rb Passes the callback to encryption I/O.
lib/​mongo/​crypt/​encryption_io.rb Invokes callbacks and applies KMS timeout conversion.
lib/​mongo/​crypt/​context.rb Forwards operation timeouts to KMS requests.
lib/​mongo/​crypt/​auto_encrypter.rb Documents and forwards the callback option.
lib/​mongo/​client.rb Documents the automatic-encryption callback option.
lib/​mongo/​client_encryption.rb Exposes the callback and documents proxy usage.
.evergreen/​run-tests.sh Adds comments explaining spec-managed proxy processes.

Review effort: Balanced

Comment thread lib/mongo/crypt/context.rb Resolved
Comment thread lib/mongo/crypt/encryption_io.rb Resolved
Comment thread lib/mongo/crypt/encryption_io.rb Resolved
Comment thread lib/mongo/crypt/context.rb Resolved
Comment thread spec/support/kms_http_proxy.rb Resolved
Comment thread lib/mongo/client_encryption.rb Resolved
jamis added 10 commits October 28, 2026 10:21
The non-callback path in with_ssl_socket goes through Address#socket,
which always defaults connect_timeout to Server::CONNECT_TIMEOUT. The
callback path only set connect_timeout when CSOT was in effect, so with
no operation budget Socket::SSL#connect! fell through to a bare blocking
connect with no deadline.

Give the callback path the same default: the remaining budget under CSOT,
otherwise Server::CONNECT_TIMEOUT.
The KMS loop snapshotted the remaining operation budget once per
state-machine iteration and reused it for every KMS context, every
in-place retry, and the libmongocrypt backoff. Later attempts could
therefore run with a stale positive timeout well past the operation
deadline.

Thread the CsotTimeoutHolder into the loop and recompute the remaining
budget before each request, raising Error::TimeoutError once the budget
is spent. Clamp the backoff sleep to the remaining budget so it cannot
overshoot the deadline.
feed_kms restarted the full operation budget for the TLS handshake, for the
request write, and for every read chunk, so a slow, chunked KMS response could
run for roughly (chunks + 2) times the budget.

Compute a single monotonic deadline for the exchange and give each phase only
the time left before it, raising Error::TimeoutError once it has passed. The
SOCKET_TIMEOUT fallback is kept per phase when no operation budget is set.
CsotTimeoutHolder#remaining_timeout_ms truncates the remaining budget,
so in the final sub-millisecond before the deadline it returned 0.
check_timeout! only fires at or past the deadline, so that 0 was
reachable, and 0 is the driver-wide sentinel for "unlimited":
Timeout.timeout(0) enforces nothing and Socket::SSL#connect! treats a
zero connect_timeout as unbounded, so a KMS request at the deadline
could block indefinitely.

Floor a finite remaining budget to 1 ms. This fixes the whole family of
consumers at the single accessor they all read from, not just the KMS
path.
The kms_connect_callback runs before any socket is constructed, so it
could not inherit the connect timeout the socket gets, and nothing
bounded it: timeout_ms was passed in but only the callback could honor
it. A callback that blocked in TCPSocket.new or a CONNECT read hung the
encryption operation regardless of the operation deadline.

Wrap the call in Timeout.timeout, using the same budget the socket gets:
the remaining CSOT budget when one is in effect, otherwise the driver's
connect timeout. The raised timeout propagates through the existing
rescue as a retryable KmsError, the same path the TLS write and read
timeouts take.

Extract that budget into a connect_budget helper so the callback and the
TLS handshake share one value, and document that the callback should
still honor timeout_ms itself so it can fail cleanly and close its
socket.
create_data_key drives the proxy on the same port, so connect_count was
already non-zero before the auto-encrypted client was built. The assertion
therefore passed even if automatic encryption never invoked the callback.
Reset the counter after creating the data key so it measures only
auto-encryption traffic.
KmsHttpProxy.start spawned the proxy and then waited for it to become
ready. If that wait raised, start never returned the pid, so the spec's
after(:all) hook had nothing to stop and the child kept holding the fixed
port (9004/9005) for later runs.

Stop and reap the child before re-raising.
The kms_connect_callback example read only the status line with a single
gets, leaving the remaining headers buffered. The TLS handshake then read
those plaintext bytes and failed. Consume the full header block up to the
blank line and check the status, matching the test helper. Also honor
timeout_ms in the example, as the surrounding docs ask callbacks to do.
…dshake

The connect callback and the TLS handshake that follows it each got the
full connect budget, so together they could run for twice the operation
deadline. Compute a single connect deadline and give the handshake only
the time the callback left, raising the timeout error if the callback
consumed the whole budget.
A CSOT deadline expiry during the KMS TLS write or read raised
Error::TimeoutError, which with_ssl_socket wrapped as a retryable network
KmsError, so feed_kms ran an extra backoff and retry cycle before the
deadline surfaced. Wrap timeouts without the network_error flag so they
surface immediately instead of being retried.
The callback result check accepted anything responding to to_io, but
Ruby's OpenSSL requires an fd-backed IO and rejects everything else with
an obscure TypeError deep inside SSLSocket.new. Require a real IO so an
SSLSocket or other non-IO return fails with the intended KmsError.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants