TCP B9: Add TLS to the native client - #563
Conversation
391fef7 to
d4cfd1b
Compare
There was a problem hiding this comment.
Pull request overview
Adds TLS support to the experimental native TCP client, including certificate validation, secure-port resolution, and Cloud smoke coverage.
Changes:
- Wraps TCP connections in
SslStreambefore protocol authentication. - Adds TLS options, connection-string keys, validation, and custom CA support.
- Adds TLS unit, integration, Cloud, and CI coverage.
Reviewed changes
Copilot reviewed 18 out of 18 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
ClickHouse.Driver.Tcp/Protocol/TlsParameters.cs |
Implements TLS setup and certificate validation. |
ClickHouse.Driver.Tcp/Protocol/ClickHouseTcpConnection.cs |
Negotiates TLS before protocol handshake. |
ClickHouse.Driver.Tcp/Client/IConnectionFactory.cs |
Builds and applies TLS configuration. |
ClickHouse.Driver.Tcp/Client/ClickHouseTcpConnectionStringBuilder.cs |
Adds TLS connection-string keys. |
ClickHouse.Driver.Tcp/Client/ClickHouseTcpClientOptions.cs |
Defines and validates TLS options. |
ClickHouse.Driver.Tcp.Tests/Utilities/TestCertificates.cs |
Generates test certificates and chains. |
ClickHouse.Driver.Tcp.Tests/Protocol/TlsParametersTests.cs |
Tests TLS validation behavior. |
ClickHouse.Driver.Tcp.Tests/Protocol/ClickHouseTcpConnectionTests.cs |
Updates connection calls for TLS parameters. |
ClickHouse.Driver.Tcp.Tests/Integration/TcpServerFixture.cs |
Preserves plaintext fixture connections. |
ClickHouse.Driver.Tcp.Tests/Integration/ClickHouseTcpConnectionIntegrationTests.cs |
Updates integration connection invocation. |
ClickHouse.Driver.Tcp.Tests/Cloud/TcpCloudIntegrationTests.cs |
Adds Cloud TLS smoke tests. |
ClickHouse.Driver.Tcp.Tests/Cloud/TcpCloudFixture.cs |
Configures Cloud TLS tests. |
ClickHouse.Driver.Tcp.Tests/Client/TcpConnectionFactoryTests.cs |
Tests end-to-end TLS factory wiring. |
ClickHouse.Driver.Tcp.Tests/Client/ClickHouseTcpConnectionStringBuilderTests.cs |
Tests TLS option parsing. |
ClickHouse.Driver.Tcp.Tests/Client/ClickHouseTcpClientOptionsTests.cs |
Tests TLS defaults and validation. |
.github/workflows/tests-tcp.yml |
Invokes the TCP Cloud suite. |
.github/workflows/tests-cloud-tcp.yml |
Defines TCP Cloud testing. |
.github/workflows/reusable-tcp.yml |
Excludes Cloud tests from standard runs. |
Suppressed comments (1)
ClickHouse.Driver.Tcp.Tests/Protocol/TlsParametersTests.cs:320
- The echo server also assumes one read receives the whole payload. A short read gets echoed as a truncated response, making the TLS tests flaky and preventing the client-side exact-read fix from completing. Limit the target memory to the payload length and fill it before echoing.
int read = await ssl.ReadAsync(buffer);
await ssl.WriteAsync(buffer.AsMemory(0, read));
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
d4cfd1b to
ebe0b96
Compare
Cloud job verified by dispatchThe The Cloud job itself: 5 passed, 0 skipped, 0 ignored. Worth stating explicitly, because the fixture calls This run is the only coverage of four things no local test can reach:
It also confirms the Follow-up, not in this PR: |
|
Both findings were valid. Fixed in Blocker — disposal racing an active handshakeConfirmed, and it contradicts what I claimed on the previous commit ("no dial can be in flight"). That holds only when the drain succeeds. I did not take the suggested fix. Rather than ref-count live handshakes, disposal is gated on the drain: if (drained) { factory.Dispose(); }Holding every permit is the proof that no dial is running — a checkout keeps its permit across its dial, and no new one can start while all are held. When the drain times out the certificates go back to the finalizer, which is exactly where they were before this call existed. Ref-counting would add concurrency machinery to a configuration object to buy prompt handle release in the pathological case only; that did not seem a good trade. If you disagree, the ref-counting version is straightforward to add on top. Regression test as requested: Major — top-level revocation ignored under pinningCorrect, and I half-knew it: that interaction is why my own test used the nested property, but I never fixed the contract, so the intuitive setting stayed dead. Two tests, and removing the normalization fails both. Note the nested-wins case cannot be proved by handshake outcome — Nits
One thing worth recording on the compliance table: the disposal failure mode was fail-closed. A disposed certificate in |
|
Two decisions from the maintainer, recorded so neither gets re-raised: No No CHANGELOG/RELEASENOTES entry. Confirmed as intended for this stack, not an oversight. Epic R3 is scoped to sweep every TCP epic and write the client up as one piece when it ships — worth knowing that R3 cannot be driven from a changelog diff, since neither file mentions Nothing outstanding on my side. B9 is complete: the transport, the option surface, the Cloud smoke suite verified by manual dispatch, and the three review rounds addressed. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 21 out of 21 changed files in this pull request and generated no new comments.
Suppressed comments (1)
ClickHouse.Driver.Tcp/Client/ClickHouseTcpClientOptions.cs:127
- The documented precedence is not true when the callback explicitly sets the policy to
NoCheckwhile setting the top-level mode toOnlineorOffline.NormalizeRevocationModecannot distinguish that assignment from the policy's initialNoCheck, so it overwrites the nested value. Document the actual rule so callers do not rely on a precedence the API cannot provide.
/// With <see cref="TlsCaCertificatePath"/> set, a <c>CertificateChainPolicy</c> is already in place, and the
/// hook receives it and may edit it. Revocation checking can be asked for either way — on
/// <c>CertificateRevocationCheckMode</c> or on the policy's own <c>RevocationMode</c> — because a value set on
/// the former is carried into the latter, which is the only one the handshake reads once a policy exists. A
/// value set directly on the policy takes precedence.
cee1a7e to
7a6c45e
Compare
7a6c45e to
05bd7c2
Compare
05bd7c2 to
67e654f
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
67e654f to
bc6bb0b
Compare
bc6bb0b to
ac06676
Compare
ac06676 to
6d63887
Compare
0ee645b to
5331687
Compare
5331687 to
5af7c2b
Compare
bd98e6f to
a284869
Compare
74a53ba to
f10550a
Compare
Wrap the socket in an SslStream between the connect and the handshake, so the ClientHello - which carries the password as plaintext - travels inside the tunnel. The native protocol sends the same bytes either way, so nothing above the transport changes. Options are flat on ClickHouseTcpClientOptions, each with a connection-string key: UseTls, TlsServerName, TlsAllowInvalidCertificates, TlsCaCertificatePath, plus a code-only ConfigureTls hook applied last for anything else. Port becomes int?, and ResolvedPort derives 9440 with TLS and 9000 without; an explicit value always wins. Three rules keep a misconfiguration from quietly running in the clear: Validate() refuses any Tls* property set while UseTls is false, refuses TlsAllowInvalidCertificates together with TlsCaCertificatePath, and the connection-string boolean getter throws on a value it cannot read instead of falling back to false. A pinned certificate authority replaces the host trust store rather than adding to it, so a certificate the host would accept on its own is refused unless it also chains to the named authority. The rebuild keeps the two checks the platform would have made - the serverAuth EKU, and the revocation mode the TLS options carry - because it replaces that check rather than supplementing it. Cloud coverage is a smoke set in its own namespace, driven by one environment variable and skipped when it is unset. It stays small on purpose: the integration suite builds Memory tables, which live on one replica, while a pooled client spreads an insert and its read-back over connections that need not land on the same one. Verified against a ClickHouse container with tcp_port_secure and a self-signed certificate: pinned-CA accept, wrong-CA reject, no-CA reject, skip-verify accept. Co-Authored-By: Claude <noreply@anthropic.com>
Four review comments, and the second is the substantial one. The pinned authorities were applied by a RemoteCertificateValidationCallback that built its own X509Chain. That meant re-applying by hand everything the handshake would have done, and silently dropping anything a ConfigureTls hook set on CertificateChainPolicy - despite the hook being documented as applied last and able to override. Pinning is now the policy itself (SslClientAuthenticationOptions.CertificateChainPolicy, net8+), so the handshake builds the chain with it: the host-name match, the serverAuth requirement and the intermediates all stay the platform's, and the hook receives the policy the handshake uses and can edit any part of it. The hand-rolled validation is gone. An empty TlsCaCertificatePath passed Validate as unset but the factory read it as set, so a client validation had just accepted failed to construct. Both sides now agree on IsNullOrEmpty. The certificate authorities are loaded for the client's lifetime and held native key handles nothing released. IConnectionFactory is disposable now, and the pool disposes it last in its teardown, once no connection can still be handshaking against them. The TLS echo tests returned a single possibly-partial read and compared it to the payload, which would fail on fragmentation rather than on anything they test. They read exactly the payload length. Also corrects two comments that overstated what is load-bearing: with the policy version the explicit serverAuth application policy is defence in depth, since the handshake applies it to a client-side chain anyway. It was load-bearing in the callback version it replaced. Co-Authored-By: Claude <noreply@anthropic.com>
The factory was iterating TlsParameters.CaCertificates and disposing its contents, which is the factory managing state it does not own. It owns the TlsParameters it built; disposing that is enough. Moving it exposed a fail-open default in the idempotency. Emptying the collection was what made a second Dispose harmless, but WrapAsync selects the pinned-roots branch on that collection being non-empty - so after a dispose the branch was skipped and the handshake validated against the host trust store instead. Exclusive pinning silently became system trust, which is the outcome pinning a private authority exists to prevent. TlsParameters now tracks disposal with a flag, deliberately leaves the collection populated, and WrapAsync refuses afterwards. Nothing reachable hit this: the pool disposes the factory last in its teardown, once every permit is back, so no dial can be in flight. It is a guard on the validation path rather than a fix for an observed failure. Co-Authored-By: Claude <noreply@anthropic.com>
Two findings, both real. Teardown could dispose the pinned certificate authorities while a dial was still handshaking. A dial holds a permit but is not in `leased`, so the abort after the drain deadline does not reach it, and disposing the factory anyway freed certificates under a live handshake - reported as a cryptographic error from inside the platform. Holding every permit is the proof that no dial is running, so the factory is now disposed only when the drain finished. A drain that timed out leaves the certificates to the finalizer, which is where they were before that call existed. Ref-counting live handshakes would buy prompt release in the pathological case only, at the price of concurrency machinery in a configuration object. The other: once a chain policy is present the platform ignores CertificateRevocationCheckMode, so pinning a certificate authority made the obvious way to ask for revocation checking silently do nothing. A value set there is now carried into the policy the handshake reads, with a value set directly on the policy taking precedence, and both forms are covered. Also from the review, and a factual error of mine: a certificate does not have to *declare* server authentication. An absent extended key usage is unrestricted (RFC 5280 4.2.1.12), and ApplicationPolicy constrains that extension when it is present rather than requiring it. And "ClientHello" in the TLS files now says which Hello it means - the native protocol's, not the TLS handshake message of the same name. Co-Authored-By: Claude <noreply@anthropic.com>
The TCP Cloud job took `secrets: inherit`, which gives it every repository secret. It declares the three Cloud credentials it uses, and the caller passes only those. The two TCP checkouts keep the job token in `.git/config`. No later step in either job talks to git, so both set `persist-credentials: false`. Both TCP jobs pin `actions/setup-dotnet@v6`, the version the rest of the workflows use. Clears the zizmor `secrets-inherit` and `artipacked` findings on the workflows this branch adds.
Reject empty TLS settings, partition configured roots from chain-building intermediates, and preserve .NET's revocation-policy precedence. Keep connection factories alive until in-flight dials complete and add deterministic race coverage. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
f10550a to
044ff19
Compare
Stacked on #562 (
tcp/epic-j5-json). Review that one first — this diff is only the TLS work.Closes epic story B9, plus a Cloud smoke suite over the new transport.
What this does
ClickHouseTcpConnection.ConnectAsynctakes aTlsParameters(null = plaintext) and wraps theNetworkStreamin anSslStreambetween the socket connect and the protocol handshake. TLS sits below the protocol, so nothing above the transport changes — the native protocol sends the same bytes either way. What it buys is that the ClientHello, which carries the password as plaintext, is already inside the tunnel.Options are flat on
ClickHouseTcpClientOptions, each with a matching connection-string key:UseTlsUseTlsTlsServerNameTlsServerNameHostTlsAllowInvalidCertificatesTlsAllowInvalidCertificatesTlsCaCertificatePathTlsCaCertificatePathConfigureTlsAction<SslClientAuthenticationOptions>escape hatch, applied lastDecisions worth a reviewer's attention
Portis nowint?.ResolvedPortderives 9440 with TLS and 9000 without; an explicit value always wins, so TLS on a non-standard port needs no other change. This is a source-breaking change to a public property — acceptable becauseClickHouse.Driver.TcpisIsPackable=false, has noPublicAPI/*.txtbaseline yet (Epic R1), and is gated behind the experimental diagnostic.A pinned authority replaces the host trust store rather than adding to it. A certificate the host would accept on its own is refused unless it also chains to the named authority. An additive check would still take a certificate mis-issued by any public authority, which is the attack pinning exists to stop. The rebuild also keeps the two checks the platform would have made — the
serverAuthEKU, and the revocation mode the TLS options carry — because it replaces the platform's check rather than supplementing it.Three guards against silently connecting in the clear.
Validate()refuses anyTls*property set whileUseTlsis false (a forgottenUseTlswith a configured CA is exactly the mistake worth catching), refusesTlsAllowInvalidCertificatestogether withTlsCaCertificatePathas contradictory, and the connection-string boolean getter throws on a value it cannot read instead of falling back to false —UseTls=yesreading as false would hand back a plaintext connection the caller believes is secure.mTLS is not implemented. Client certificates are reachable only through
ConfigureTls. A declarative surface is filed as its own story: it also interacts with the handshake credential fields and with how the server maps a certificate subject to a user.Cloud tests
New
Tests/Cloud,[Category("Cloud")], driven by one environment variable holding a native connection string. It ignores itself when unset, and fails if that string does not setUseTls— otherwise the suite would keep passing over a plaintext connection, proving nothing about the transport it exists to cover. Own namespace, so theIntegrationSetUpFixturedoes not start a container for it.Deliberately a smoke set (handshake + ping, query, MergeTree insert round-trip, pooled session reuse, concurrency) rather than the whole integration suite: 50 of that suite's 51 tables are
Memoryengine, which lives on one replica, while a pooled client spreads an insert and its read-back over connections that need not land on the same one. Widening it means converting those tables or pinningMaxPoolSize=1— filed separately.CI: new
tests-cloud-tcp.yml, called fromtests-tcp.ymlon push and manual dispatch only, not onpull_request— every stackedtcp/**epic PR would otherwise queue another run against the shared Cloud service. It carries the same fork guard as the HTTP cloud workflow.reusable-tcp.ymlnow filtersCategory!=Cloud.Testing
TlsParametersTestsdrives real TLS handshakes against an in-processSslStreamserver, covering what no cloud service can be asked to present: a self-signed certificate, one from a private authority, one for the wrong purpose, and a chain through an intermediate.TcpConnectionFactoryTestscovers the wiringUseTls→BuildTlsParameters→ConnectAsyncagainst a TLS listener that speaks the server Hello, plus the negative that a plaintext client fails there — without that test nothing on the standard CI matrix touched the wiring, only the Cloud job.Each fix was mutation-checked: dropping the EKU, hardcoding
NoCheck, dropping theExtraStoreloop, and inverting theUseTlstest each fail exactly their own test.Verified end-to-end against a ClickHouse container with
tcp_port_secureand a self-signed certificate — pinned-CA accept, wrong-CA reject, no-CA reject (UntrustedRoot), skip-verify accept — and the session-reuse test confirmed load-bearing by forcing the pool to retire between operations.Full suite: 2652 passed, 0 failed on net9.0. Coverage on the new
TlsParameters.csis 96.4%; the only uncovered lines are a documented unreachable defensive guard.No CHANGELOG/RELEASENOTES entry. This is a deliberate stack-level exception, not an oversight: neither file mentions
ClickHouse.Driver.Tcpat all today, the assembly isIsPackable=falseand gated behind an experimental diagnostic, and an earlier commit on this stack (07c59bd4) explicitly removed an entry to keep these PRs to the TCP projects. Epic R3 adds the entries as one piece when the client ships. Raised in review and confirmed as the intended behaviour for this stack, so it is settled rather than outstanding. R3 is scoped to sweep every epic, not just the last one.🤖 Generated with Claude Code