Skip to content

Scope the Network.framework mTLS identity to its origin - #18

Draft
o-nnerb wants to merge 4 commits into
mainfrom
claude/mtls-identity-per-request-1f4542
Draft

o-nnerb wants to merge 4 commits into
mainfrom
claude/mtls-identity-per-request-1f4542

Conversation

@o-nnerb

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

Copy link
Copy Markdown
Member

Problem

HTTPClient.Configuration.tlsLocalIdentityNetworkFramework is one client-wide identity. On the Network.framework (NIOTS) backend every connection presented it to any server that requested a client certificate — including the target of a redirect to a different host. Apple's URLSession and browsers pick the certificate per challenge/origin instead, and .urlSession in request-dl already follows that model; only the NIOTS path leaked.

Change

  • New tlsLocalIdentityProviderNetworkFramework: (@Sendable (_ host: String, _ port: Int) -> SecIdentity?)?. It is asked for the origin each 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. This needs neither the redirect-strategy PR nor any change to the redirect loop.
  • The host is the one named in the URL (SNI override under a DNS override), IPv6 brackets are stripped, unix sockets never consult the provider.
  • The provider takes precedence over tlsLocalIdentityNetworkFramework. The unscoped property is kept for source compatibility (it is already shipped on release) and its doc now carries a warning pointing at the provider.

PR dependencies

This branch is main + the merge of #12 (mtls-network-framework-identity), which introduces the identity hook this change scopes. #12 must land first, or this supersedes it. The redirect PR (#9) is not required. The diff of this PR on top of #12 is the single commit df259ec.

Tests

LocalIdentityNetworkFrameworkTests (7 tests, all run, none skipped):

  • Redirect from 127.0.0.1 to an mTLS server at localhost, identity configured only for 127.0.0.1 → handshake fails. Reverting the scoping makes this test fail, so it genuinely detects the leak.
  • Same redirect with the identity configured for localhost → 200 (positive control).
  • Provider receives the right origin; IPv6 brackets/unix sockets handled.

While doing this I found the #12 identity tests were always skipped: the Keychain round-trip in TestIdentityBuilder failed with errSecItemNotFound, and the client never trusted the self-signed server. They now build the SecIdentity from an in-memory PKCS#12 bundle (kSecImportToMemoryOnly, so macOS 15 / iOS 18+, otherwise XCTSkip) and use certificateVerification: .none on the client. The Keychain builder, DER reader and the two TestTLS DER helpers it needed are removed.

Not covered: the .nio (NIOSSL) backend still has one client-wide certificateChain/privateKey and is a separate change. In the wider run (600 tests), the only failures were testConnectTimeout in HTTPClientTests and AsyncAwaitEndToEndTests ("connection reset by peer" instead of a timeout). They fail identically on a clean main (4c005f9), so they are pre-existing and unrelated to this change (most likely the local network environment).

🤖 Generated with Claude Code

o-nnerb and others added 4 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>
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.

1 participant