Skip to content

Scope the NIOSSL mTLS identity to its origin - #20

Draft
o-nnerb wants to merge 7 commits into
mainfrom
claude/nio-identity-per-origin
Draft

o-nnerb wants to merge 7 commits into
mainfrom
claude/nio-identity-per-origin

Conversation

@o-nnerb

@o-nnerb o-nnerb commented Oct 7, 2026 •

Copy link
Copy Markdown
Member

Companion to #18, which does this for the Network.framework backend. Same problem, same model, for connections that use NIOSSL: every connection on platforms without Network.framework (Linux, the default executor there), on Apple platforms when the event loop is not a Network.framework one, and proxied connections everywhere.

Problem

An identity configured through certificateChain/privateKey — on HTTPClient.Configuration.tlsConfiguration, or on a request's own TLS configuration (which redirects preserve since swift-server#928) — is presented to every server the client connects to that asks for a client certificate, including the target of a redirect to a different host. URLSession and browsers choose the certificate per challenge/origin instead.

Change

  • New tlsLocalIdentityProviderNIOSSL: (@Sendable (_ host: String, _ port: Int) -> NIOSSLClientIdentity?)? with a small NIOSSLClientIdentity (certificateChain + privateKey) value type. It is asked for the origin each connection is opened to; nil presents none.
  • Connections are per origin, so a redirect to another host asks the provider again with that host. No change to the redirect loop; the redirect-strategy PR is not needed.
  • When set, the provider is the only source of the client identity: its answer replaces certificateChain/privateKey in the TLS configuration used for the connection (cleared when it returns nil), whether that configuration came from the client or from the request. This is what actually closes the leak. Without a provider nothing changes, so existing users are unaffected; moving to the provider is a caller-side opt-in.
  • Applied at both NIOSSL sites in the connection factory: direct connections and TLS inside a proxy tunnel. The Network.framework path is untouched by it (it would otherwise hit TLSConfiguration.certificateChain's precondition there).
  • The host is the one named in the URL (SNI override under a DNS override), IPv6 brackets stripped, unix sockets get no identity. This shares one ConnectionPool.Key.origin with the Network.framework provider, and the NWF helper now takes that origin.

Dependencies

Stacked on #18 (which is itself main + #12): this branch is that one plus the single commit on top. Land #18 first, or this supersedes it.

Tests

LocalIdentityNIOSSLTests (11, new) always run on a MultiThreadedEventLoopGroup, which selects NIOSSL on Apple platforms too:

  • Redirect from 127.0.0.1 to an mTLS server at localhost, identity only for 127.0.0.1 → handshake fails; with the identity configured for localhost → 200.
  • A provider answering nil beats an identity set in tlsConfiguration and one in a request's own TLS configuration.
  • No provider: identity in tlsConfiguration still works (unchanged behaviour); an mTLS server without any certificate is rejected (negative control).
  • Provider receives the right origin.

Disabling the provider logic makes the redirect and origin tests fail. Full suite: 608 tests, the only failures are the two testConnectTimeout ("connection reset by peer"), which fail identically on a clean main (see #18).

Proxy tunnel: three tests go through a simulated CONNECT proxy that terminates TLS and demands a client certificate: the provider is asked for the destination origin (not the proxy), and the identity is presented or withheld accordingly. Removing the tunnel call site makes two of them fail.

🤖 Generated with Claude Code

o-nnerb and others added 5 commits September 8, 2026 18:34
Exposes HTTPClient.Configuration.tlsLocalIdentityNetworkFramework: a
thin passthrough into Network.framework's
sec_protocol_options_set_local_identity, for direct (non-proxied)
connections on Apple platforms. tlsConfiguration.certificateChain and
.privateKey (the NIOSSL-shaped mTLS config) remain unsupported on this
backend, same as before -- there's no public API to build a
SecIdentity from raw bytes without a Keychain round-trip, so this hook
takes an already-built SecIdentity rather than AsyncHTTPClient
performing that round-trip itself.

Tests cover both that the client certificate is actually presented to
a server that requires one, and the negative control (connection
rejected without it). Synthesizing a Keychain-backed SecIdentity
inside an unsigned `swift test` process is itself unreliable -- the
positive test skips rather than flakes when that round-trip can't
complete, same limitation RequestDL's own RawBytesIdentityBuilder
test suite already works around by only unit-testing its DER-parsing
halves.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… into claude/mtls-identity-per-request-1f4542
`tlsLocalIdentityNetworkFramework` is a single client-wide identity, so every
connection opened by a client that uses Network.framework presented it to any
server that asked for a client certificate, including the target of a redirect
to a different host. Apple's own URLSession (and browsers) choose the
certificate per challenge/origin instead.

Add `tlsLocalIdentityProviderNetworkFramework`, a closure that receives the host
and port of the origin a connection is opened to and returns the identity to
present (or nil for none). Connections are per origin, so a redirect to another
host asks the provider again with that host and an identity meant for the
original host is never sent to it. The provider takes precedence over the
unscoped property, which is kept for source compatibility and documented as
not origin-scoped.

The tests build the SecIdentity from an in-memory PKCS#12 bundle
(kSecImportToMemoryOnly) rather than a Keychain round-trip, which failed to find
the key it had just added and made every identity test skip, and configure the
client not to verify the self-signed test server so the handshake can complete.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Setting `certificateChain`/`privateKey` on `tlsConfiguration` (or on a request's
own TLS configuration, which redirects preserve) presents that identity to every
server the client connects to, including the target of a redirect to a different
host. URLSession and browsers choose the certificate per challenge/origin.

Add `tlsLocalIdentityProviderNIOSSL`, a closure that receives the host and port
of the origin a connection is opened to and returns the identity to present (or
nil for none). When it is set, it is the only source of the client identity: its
answer replaces the certificate chain and private key of the TLS configuration
used for the connection, for both direct and proxy-tunnelled connections. Without
a provider behaviour is unchanged.

Share the origin computation with the Network.framework provider through
`ConnectionPool.Key.origin`.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
o-nnerb and others added 2 commits October 7, 2026 19:58
The proxy-tunnel TLS setup is a separate call site from the direct-connection
one. Cover that the provider is asked for the destination origin (not the proxy)
and that the identity it chooses is, or is not, presented through the tunnel.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…OSSL's doc comment

TLSConfiguration is NIOSSL's type, so DocC can't resolve it as a symbol link of this module.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@o-nnerb o-nnerb added the 🆕 semver/minor Additive, non-breaking API change label Oct 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🆕 semver/minor Additive, non-breaking API change

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant