From 87c3e5a4343248fc0df24204340c8d8ebe9a11c4 Mon Sep 17 00:00:00 2001 From: brennobemoura <37243584+brennobemoura@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:38:18 -0300 Subject: [PATCH 1/4] Scope the Network.framework mTLS identity to its origin `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 --- Sources/AsyncHTTPClient/ConnectionPool.swift | 14 + .../HTTPConnectionPool+Factory.swift | 5 +- Sources/AsyncHTTPClient/HTTPClient.swift | 23 ++ .../TLSConfiguration.swift | 21 ++ .../HTTPClientTestUtils.swift | 16 - .../LocalIdentityNetworkFrameworkTests.swift | 329 ++++++++++-------- 6 files changed, 251 insertions(+), 157 deletions(-) diff --git a/Sources/AsyncHTTPClient/ConnectionPool.swift b/Sources/AsyncHTTPClient/ConnectionPool.swift index a659df27b..719515fb4 100644 --- a/Sources/AsyncHTTPClient/ConnectionPool.swift +++ b/Sources/AsyncHTTPClient/ConnectionPool.swift @@ -103,6 +103,20 @@ extension DeconstructedURL { } } +extension ConnectionPool.Key { + /// The host the request named, i.e. what a user (or a certificate) knows the server as. That is + /// not the connection target's host when a DNS override is in effect. + /// + /// Only `nil` for unix sockets. + var originHost: String? { + self.serverNameIndicatorOverride ?? self.connectionTarget.host + } + + var originPort: Int? { + self.connectionTarget.port + } +} + extension ConnectionPool.Key { init( url: DeconstructedURL, diff --git a/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift b/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift index 8c332cc77..ff5241bf9 100644 --- a/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift +++ b/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift @@ -664,7 +664,10 @@ extension HTTPConnectionPool.ConnectionFactory { on: eventLoop, serverNameIndicatorOverride: key.serverNameIndicatorOverride, customVerification: self.clientConfiguration.tlsCustomVerificationNetworkFramework, - localIdentity: self.clientConfiguration.tlsLocalIdentityNetworkFramework + localIdentity: self.clientConfiguration.localIdentityNetworkFramework( + forHost: self.key.originHost, + port: self.key.originPort + ) ).map { options -> NIOClientTCPBootstrapProtocol in diff --git a/Sources/AsyncHTTPClient/HTTPClient.swift b/Sources/AsyncHTTPClient/HTTPClient.swift index 39f61fd8d..9e4e1f147 100644 --- a/Sources/AsyncHTTPClient/HTTPClient.swift +++ b/Sources/AsyncHTTPClient/HTTPClient.swift @@ -1028,7 +1028,29 @@ public final class HTTPClient: Sendable { /// then look them back up as a paired `kSecClassIdentity` item) produces one. AsyncHTTPClient /// does not perform that round-trip itself; a caller who already has a Keychain-backed identity /// (or has already done that round-trip) hands it over directly here. + /// + /// - Warning: This identity is not scoped to an origin. It is offered to **every** server a + /// connection is opened to, including the targets of redirects. Prefer + /// ``tlsLocalIdentityProviderNetworkFramework``, which is only given the identity's own + /// origin. Ignored when ``tlsLocalIdentityProviderNetworkFramework`` is set. public var tlsLocalIdentityNetworkFramework: SecIdentity? + + /// Chooses the client identity (certificate + private key) to present for mTLS, per origin, on + /// direct (non-proxied) connections that use Network.framework instead of NIOSSL. + /// + /// This follows the model of `URLSession`'s authentication challenge: the identity is selected + /// for the origin that is actually being connected to, and returning `nil` presents none. A + /// connection is opened per origin, so a redirect to a different host asks the provider again + /// with that host, and an identity meant for the original host is never sent to it. + /// + /// The closure receives the host and port of the origin the request targets (an IPv6 literal + /// is passed without its square brackets, and the host is the one named in the URL even when a + /// DNS override is configured). It is called on the connection's event loop each time a + /// connection is opened, so it must be cheap and must not block. + /// + /// See ``tlsLocalIdentityNetworkFramework`` for how to obtain a `SecIdentity`. Takes precedence + /// over it when both are set. + public var tlsLocalIdentityProviderNetworkFramework: (@Sendable (_ host: String, _ port: Int) -> SecIdentity?)? #endif public init( @@ -1054,6 +1076,7 @@ public final class HTTPClient: Sendable { #if canImport(Network) self.tlsCustomVerificationNetworkFramework = nil self.tlsLocalIdentityNetworkFramework = nil + self.tlsLocalIdentityProviderNetworkFramework = nil #endif } diff --git a/Sources/AsyncHTTPClient/NIOTransportServices/TLSConfiguration.swift b/Sources/AsyncHTTPClient/NIOTransportServices/TLSConfiguration.swift index 1dc1474c9..02f3c4e51 100644 --- a/Sources/AsyncHTTPClient/NIOTransportServices/TLSConfiguration.swift +++ b/Sources/AsyncHTTPClient/NIOTransportServices/TLSConfiguration.swift @@ -280,6 +280,27 @@ extension TLSConfiguration { } } +extension HTTPClient.Configuration { + /// The client identity to present on a connection opened to `host`:`port`, if any. + /// + /// A connection is bound to a single origin, and redirects to another origin open a new connection + /// to it, so deciding here — rather than once for the whole client — is what keeps an identity from + /// following a redirect to a host it was not meant for. `nil` host/port (unix sockets) never + /// consult the provider. + func localIdentityNetworkFramework(forHost host: String?, port: Int?) -> SecIdentity? { + if let provider = self.tlsLocalIdentityProviderNetworkFramework { + guard var host, let port else { + return nil + } + if host.hasPrefix("["), host.hasSuffix("]") { + host = String(host.dropFirst().dropLast()) + } + return provider(host, port) + } + return self.tlsLocalIdentityNetworkFramework + } +} + enum NWLocalIdentityError: Error, CustomStringConvertible { case identityCreationFailed diff --git a/Tests/AsyncHTTPClientTests/HTTPClientTestUtils.swift b/Tests/AsyncHTTPClientTests/HTTPClientTestUtils.swift index 7e9d3a4be..36cc9aec7 100644 --- a/Tests/AsyncHTTPClientTests/HTTPClientTestUtils.swift +++ b/Tests/AsyncHTTPClientTests/HTTPClientTestUtils.swift @@ -362,22 +362,6 @@ enum TestTLS { certificateChain: [.certificate(TestTLS.certificate)], privateKey: .privateKey(TestTLS.privateKey) ) - - /// DER-encoded form of `certificate`, for APIs (like `SecCertificateCreateWithData`) that need - /// raw bytes rather than a parsed `NIOSSLCertificate`. - static let certificateDER: [UInt8] = try! certificate.toDERBytes() - - /// `key` (a PKCS#8-wrapped RSA private key, "BEGIN PRIVATE KEY") with its PEM armor stripped - /// down to the raw DER payload — the PKCS#8 envelope itself, not yet unwrapped to bare PKCS#1. - static let privateKeyPKCS8DER: [UInt8] = { - let base64 = - key - .split(separator: "\n") - .map { $0.trimmingCharacters(in: .whitespaces) } - .filter { !$0.hasPrefix("-----") } - .joined() - return Array(Data(base64Encoded: base64)!) - }() } #if compiler(>=6.2) diff --git a/Tests/AsyncHTTPClientTests/LocalIdentityNetworkFrameworkTests.swift b/Tests/AsyncHTTPClientTests/LocalIdentityNetworkFrameworkTests.swift index ac22f52e8..6856c524a 100644 --- a/Tests/AsyncHTTPClientTests/LocalIdentityNetworkFrameworkTests.swift +++ b/Tests/AsyncHTTPClientTests/LocalIdentityNetworkFrameworkTests.swift @@ -14,6 +14,7 @@ import NIOConcurrencyHelpers import NIOCore +import NIOHTTP1 import NIOSSL import XCTest @@ -50,24 +51,7 @@ final class LocalIdentityNetworkFrameworkTests: XCTestCase { func testClientCertificateIsPresentedOverNetworkFramework() throws { guard isTestingNIOTS() else { return } - // Synthesizing a Keychain-backed SecIdentity from raw bytes inside an unsigned `swift test` - // process is itself unreliable — the same reason RequestDL's own RawBytesIdentityBuilder test - // suite only unit-tests its DER-parsing halves and never exercises the actual Keychain - // round-trip end-to-end. Skip rather than flake/fail when that round-trip itself can't - // complete in this environment; it says nothing about tlsLocalIdentityNetworkFramework or - // sec_protocol_options_set_local_identity, which take a SecIdentity as a given. - let handle: TestIdentityBuilder.Handle - do { - handle = try TestIdentityBuilder.makeIdentity( - certificateDER: Data(TestTLS.certificateDER), - privateKeyPKCS8DER: Data(TestTLS.privateKeyPKCS8DER) - ) - } catch { - throw XCTSkip( - "Could not synthesize a Keychain-backed SecIdentity in this environment: \(error)" - ) - } - defer { TestIdentityBuilder.remove(handle) } + let identity = try TestIdentityBuilder.makeIdentity() // The server requires and validates a client certificate, trusting only TestTLS.certificate // itself (it's self-signed, so it is its own trust anchor). @@ -75,8 +59,8 @@ final class LocalIdentityNetworkFrameworkTests: XCTestCase { serverConfig.certificateVerification = .noHostnameVerification serverConfig.trustRoots = .certificates([TestTLS.certificate]) - var config = HTTPClient.Configuration() - config.tlsLocalIdentityNetworkFramework = handle.identity + var config = HTTPClient.Configuration(certificateVerification: .none) + config.tlsLocalIdentityNetworkFramework = identity let httpBin = HTTPBin(.http1_1(tlsConfiguration: serverConfig)) let httpClient = HTTPClient(eventLoopGroupProvider: .shared(self.clientGroup), configuration: config) @@ -99,7 +83,7 @@ final class LocalIdentityNetworkFrameworkTests: XCTestCase { // No tlsLocalIdentityNetworkFramework configured — the negative control proving the server // above genuinely enforces mTLS, so the positive test isn't a false pass. - let config = HTTPClient.Configuration().enableFastFailureModeForTesting() + let config = HTTPClient.Configuration(certificateVerification: .none).enableFastFailureModeForTesting() let httpBin = HTTPBin(.http1_1(tlsConfiguration: serverConfig)) let httpClient = HTTPClient(eventLoopGroupProvider: .shared(self.clientGroup), configuration: config) @@ -110,151 +94,216 @@ final class LocalIdentityNetworkFrameworkTests: XCTestCase { XCTAssertThrowsError(try httpClient.get(url: "https://localhost:\(httpBin.port)/get").wait()) } - #endif -} -#if canImport(Network) -/// Minimal, test-only "raw bytes -> SecIdentity via a Keychain round-trip" builder for RSA/PKCS#8 -/// keys only — there is no public API on Apple platforms to pair a certificate and private key into -/// a `SecIdentity` purely in memory, so this mirrors (in miniature) the same technique RequestDL's -/// own `Internals.RawBytesIdentityBuilder` uses for its `.urlSession` executor. -enum TestIdentityBuilder { - struct Handle { - let identity: SecIdentity - fileprivate let label: String - } + // MARK: - Per-origin identity (tlsLocalIdentityProviderNetworkFramework) - enum Error: Swift.Error { - case keychainOperationFailed(OSStatus, operation: String) - case identityLookupReturnedWrongType - case malformedDER + /// An mTLS server (trusting only `TestTLS.certificate`) reachable as `https://localhost:`. + private func makeClientCertificateRequiringServer() -> HTTPBin { + var serverConfig = TestTLS.serverConfiguration + serverConfig.certificateVerification = .noHostnameVerification + serverConfig.trustRoots = .certificates([TestTLS.certificate]) + return HTTPBin(.http1_1(tlsConfiguration: serverConfig)) } - static func makeIdentity(certificateDER: Data, privateKeyPKCS8DER: Data) throws -> Handle { - guard let certificate = SecCertificateCreateWithData(nil, certificateDER as CFData) else { - throw Error.malformedDER + func testProviderIsAskedForTheOriginOfEachConnection() throws { + guard isTestingNIOTS() else { return } + + let requestedOrigins = NIOLockedValueBox<[String]>([]) + var config = HTTPClient.Configuration(certificateVerification: .none).enableFastFailureModeForTesting() + config.tlsLocalIdentityProviderNetworkFramework = { host, port in + requestedOrigins.withLockedValue { $0.append("\(host):\(port)") } + return nil } - let pkcs1DER = try unwrapPKCS8(privateKeyPKCS8DER) - - let attributes: [CFString: Any] = [ - kSecAttrKeyType: kSecAttrKeyTypeRSA, - kSecAttrKeyClass: kSecAttrKeyClassPrivate, - ] - var creationError: Unmanaged? - guard let secKey = SecKeyCreateWithData(pkcs1DER as CFData, attributes as CFDictionary, &creationError) else { - throw Error.keychainOperationFailed(errSecParam, operation: "SecKeyCreateWithData") + + let httpBin = self.makeClientCertificateRequiringServer() + let httpClient = HTTPClient(eventLoopGroupProvider: .shared(self.clientGroup), configuration: config) + defer { + XCTAssertNoThrow(try httpClient.syncShutdown()) + XCTAssertNoThrow(try httpBin.shutdown()) } - let label = "AsyncHTTPClientTests.mtls." + UUID().uuidString - - // `swift test` has no `keychain-access-groups` entitlement, which the data-protection - // keychain requires on macOS — forcing the legacy file-based keychain sidesteps that. - let useDataProtectionKeychain = false - - try addToKeychain( - query: [ - kSecClass: kSecClassKey, - kSecValueRef: secKey, - kSecAttrLabel: label, - kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly, - kSecUseDataProtectionKeychain: useDataProtectionKeychain, - ], - operation: "SecItemAdd(key)" - ) - try addToKeychain( - query: [ - kSecClass: kSecClassCertificate, - kSecValueRef: certificate, - kSecAttrLabel: label, - kSecUseDataProtectionKeychain: useDataProtectionKeychain, - ], - operation: "SecItemAdd(certificate)" - ) + // The server requires a certificate and the provider has none, so this fails; what matters is + // that the provider was consulted with the origin that was being connected to. + XCTAssertThrowsError(try httpClient.get(url: "https://localhost:\(httpBin.port)/get").wait()) + XCTAssertEqual(requestedOrigins.withLockedValue { $0 }, ["localhost:\(httpBin.port)"]) + } - // macOS-only shortcut (deliberately avoided by RequestDL's own RawBytesIdentityBuilder, which - // needs to generalize to iOS/tvOS/watchOS): pairs the certificate with a private key already - // in the keychain directly, instead of listing every identity and matching by certificate - // bytes. Test-only code, macOS being the only platform `swift test` runs on for this fork. - var matchingIdentity: SecIdentity? - let identityStatus = SecIdentityCreateWithCertificate(nil, certificate, &matchingIdentity) - guard identityStatus == errSecSuccess, let matchingIdentity else { - throw Error.keychainOperationFailed(identityStatus, operation: "SecIdentityCreateWithCertificate") + func testProviderDoesNotSeeIPv6BracketsOrUnixSockets() { + var config = HTTPClient.Configuration(certificateVerification: .none) + let requestedOrigins = NIOLockedValueBox<[String]>([]) + config.tlsLocalIdentityProviderNetworkFramework = { host, port in + requestedOrigins.withLockedValue { $0.append("\(host)|\(port)") } + return nil } - return Handle(identity: matchingIdentity, label: label) + XCTAssertNil(config.localIdentityNetworkFramework(forHost: "[::1]", port: 8443)) + XCTAssertNil(config.localIdentityNetworkFramework(forHost: "example.com", port: 443)) + XCTAssertNil(config.localIdentityNetworkFramework(forHost: nil, port: nil)) + XCTAssertEqual(requestedOrigins.withLockedValue { $0 }, ["::1|8443", "example.com|443"]) } - static func remove(_ handle: Handle) { - for itemClass in [kSecClassKey, kSecClassCertificate] { - let query: [CFString: Any] = [ - kSecClass: itemClass, - kSecAttrLabel: handle.label, - kSecUseDataProtectionKeychain: false, - ] - SecItemDelete(query as CFDictionary) + func testIdentityFromProviderIsPresentedToTheOriginItIsConfiguredFor() throws { + guard isTestingNIOTS() else { return } + + let identity = try TestIdentityBuilder.makeIdentity() + + var config = HTTPClient.Configuration(certificateVerification: .none) + config.tlsLocalIdentityProviderNetworkFramework = { host, _ in + host == "localhost" ? identity : nil + } + + let httpBin = self.makeClientCertificateRequiringServer() + let httpClient = HTTPClient(eventLoopGroupProvider: .shared(self.clientGroup), configuration: config) + defer { + XCTAssertNoThrow(try httpClient.syncShutdown()) + XCTAssertNoThrow(try httpBin.shutdown()) } - } - /// Unwraps PKCS#8's `PrivateKeyInfo ::= SEQUENCE { version INTEGER, algorithm SEQUENCE, - /// privateKey OCTET STRING, ... }` down to its inner PKCS#1 `privateKey` field, which is what - /// `SecKeyCreateWithData` wants for RSA (it has no direct entry point for PKCS#8). - private static func unwrapPKCS8(_ der: Data) throws -> Data { - var reader = DERReader(Array(der)) - var envelope = DERReader(try reader.readSequence()) - _ = try envelope.read(tag: 0x02) // version INTEGER, value unused - _ = try envelope.readSequence() // algorithm identifier, OID unused - return Data(try envelope.read(tag: 0x04)) // privateKey OCTET STRING + XCTAssertNoThrow(try httpClient.get(url: "https://localhost:\(httpBin.port)/get").wait()) } - private static func addToKeychain(query: [CFString: Any], operation: String) throws { - var result: CFTypeRef? - let status = SecItemAdd(query as CFDictionary, &result) - // A previous run leaving this exact certificate/key behind (content-derived duplicate - // detection, not label-based) already reached the state this call wants — treat as success. - guard status == errSecSuccess || status == errSecDuplicateItem else { - throw Error.keychainOperationFailed(status, operation: operation) + func testIdentityIsNotPresentedToTheTargetOfARedirectToAnotherHost() throws { + guard isTestingNIOTS() else { return } + + let identity = try TestIdentityBuilder.makeIdentity() + + // The identity is meant for 127.0.0.1 only. That server answers with a redirect to + // https://localhost, which demands a client certificate: following the redirect must not + // hand the identity over, so the handshake has to fail. + var config = HTTPClient.Configuration(certificateVerification: .none).enableFastFailureModeForTesting() + config.tlsLocalIdentityProviderNetworkFramework = { host, _ in + host == "127.0.0.1" ? identity : nil } + + let redirector = HTTPBin() + let mTLSServer = self.makeClientCertificateRequiringServer() + let httpClient = HTTPClient(eventLoopGroupProvider: .shared(self.clientGroup), configuration: config) + defer { + XCTAssertNoThrow(try httpClient.syncShutdown()) + XCTAssertNoThrow(try mTLSServer.shutdown()) + XCTAssertNoThrow(try redirector.shutdown()) + } + + XCTAssertThrowsError( + try httpClient.get( + url: "http://127.0.0.1:\(redirector.port)/redirect/https?port=\(mTLSServer.port)" + ).wait() + ) } -} -/// Minimal DER TLV (tag-length-value) reader — only as much as unwrapping a PKCS#8 envelope needs. -private struct DERReader { - private let bytes: [UInt8] - private var offset = 0 + func testIdentityIsPresentedToTheTargetOfARedirectWhenItIsTheConfiguredHost() throws { + guard isTestingNIOTS() else { return } + + let identity = try TestIdentityBuilder.makeIdentity() + + // Same redirect as above, but the identity is configured for the redirect's destination: this + // is the positive control proving the failure above comes from the scoping rather than from + // the redirect itself. + var config = HTTPClient.Configuration(certificateVerification: .none) + config.tlsLocalIdentityProviderNetworkFramework = { host, _ in + host == "localhost" ? identity : nil + } + + let redirector = HTTPBin() + let mTLSServer = self.makeClientCertificateRequiringServer() + let httpClient = HTTPClient(eventLoopGroupProvider: .shared(self.clientGroup), configuration: config) + defer { + XCTAssertNoThrow(try httpClient.syncShutdown()) + XCTAssertNoThrow(try mTLSServer.shutdown()) + XCTAssertNoThrow(try redirector.shutdown()) + } - init(_ bytes: [UInt8]) { - self.bytes = bytes + let response = try httpClient.get( + url: "http://127.0.0.1:\(redirector.port)/redirect/https?port=\(mTLSServer.port)" + ).wait() + XCTAssertEqual(response.status, .ok) } + #endif +} - mutating func readSequence() throws -> [UInt8] { - try read(tag: 0x30) +#if canImport(Network) +/// Test-only construction of a `SecIdentity` for `TestTLS`'s certificate and key. +/// +/// There is no public API on Apple platforms to pair a certificate and private key into a +/// `SecIdentity` from raw bytes, and a Keychain round-trip is unreliable inside an unsigned +/// `swift test` process (it fails to find the key it just added, depending on the keychain setup). +/// Importing a PKCS#12 bundle with `kSecImportToMemoryOnly` produces the identity without touching +/// any keychain. +enum TestIdentityBuilder { + enum Error: Swift.Error { + case pkcs12ImportFailed(OSStatus) + case noIdentityInBundle } - mutating func read(tag: UInt8) throws -> [UInt8] { - guard offset < bytes.count, bytes[offset] == tag else { - throw TestIdentityBuilder.Error.malformedDER - } - offset += 1 - - guard offset < bytes.count else { throw TestIdentityBuilder.Error.malformedDER } - var length = Int(bytes[offset]) - offset += 1 - - if length & 0x80 != 0 { - let lengthByteCount = length & 0x7F - guard lengthByteCount > 0, lengthByteCount <= 4, offset + lengthByteCount <= bytes.count else { - throw TestIdentityBuilder.Error.malformedDER - } - length = 0 - for _ in 0.. SecIdentity { + // `kSecImportToMemoryOnly` is what keeps this off the keychain; before these OS versions an + // import always lands in one, which is what this helper exists to avoid. + guard #available(macOS 15, iOS 18, tvOS 18, watchOS 11, *) else { + throw XCTSkip("SecPKCS12Import(kSecImportToMemoryOnly:) needs macOS 15 / iOS 18 or newer") } - guard offset + length <= bytes.count else { throw TestIdentityBuilder.Error.malformedDER } - defer { offset += length } - return Array(bytes[offset..<(offset + length)]) + let bundle = Data(base64Encoded: Self.pkcs12Base64, options: .ignoreUnknownCharacters)! + var items: CFArray? + let status = SecPKCS12Import( + bundle as CFData, + [kSecImportExportPassphrase: "test", kSecImportToMemoryOnly: true] as CFDictionary, + &items + ) + guard status == errSecSuccess else { + throw Error.pkcs12ImportFailed(status) + } + guard + let entry = (items as? [[String: Any]])?.first, + let identity = entry[kSecImportItemIdentity as String] + else { + throw Error.noIdentityInBundle + } + return identity as! SecIdentity } } #endif From 68e7c0832b13bddb6dd3973b7dd000bb198de4a5 Mon Sep 17 00:00:00 2001 From: brennobemoura <37243584+brennobemoura@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:47:23 -0300 Subject: [PATCH 2/4] Scope the NIOSSL mTLS identity to its origin 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 --- Sources/AsyncHTTPClient/ConnectionPool.swift | 22 +- .../HTTPConnectionPool+Factory.swift | 7 +- Sources/AsyncHTTPClient/HTTPClient.swift | 56 +++++ .../TLSConfiguration.swift | 15 +- .../LocalIdentityNIOSSLTests.swift | 220 ++++++++++++++++++ .../LocalIdentityNetworkFrameworkTests.swift | 20 +- 6 files changed, 314 insertions(+), 26 deletions(-) create mode 100644 Tests/AsyncHTTPClientTests/LocalIdentityNIOSSLTests.swift diff --git a/Sources/AsyncHTTPClient/ConnectionPool.swift b/Sources/AsyncHTTPClient/ConnectionPool.swift index 719515fb4..47119579b 100644 --- a/Sources/AsyncHTTPClient/ConnectionPool.swift +++ b/Sources/AsyncHTTPClient/ConnectionPool.swift @@ -104,16 +104,22 @@ extension DeconstructedURL { } extension ConnectionPool.Key { - /// The host the request named, i.e. what a user (or a certificate) knows the server as. That is - /// not the connection target's host when a DNS override is in effect. + /// The origin the request named, as handed to the per-origin TLS identity providers + /// (`tlsLocalIdentityProvider…`): what a user (or a certificate) knows the server as. The host is + /// the URL's, which is not the connection target's host when a DNS override is in effect, and an + /// IPv6 literal comes without its square brackets. /// /// Only `nil` for unix sockets. - var originHost: String? { - self.serverNameIndicatorOverride ?? self.connectionTarget.host - } - - var originPort: Int? { - self.connectionTarget.port + var origin: (host: String, port: Int)? { + guard var host = self.serverNameIndicatorOverride ?? self.connectionTarget.host, + let port = self.connectionTarget.port + else { + return nil + } + if host.hasPrefix("["), host.hasSuffix("]") { + host = String(host.dropFirst().dropLast()) + } + return (host, port) } } diff --git a/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift b/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift index ff5241bf9..3402f5605 100644 --- a/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift +++ b/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift @@ -451,6 +451,7 @@ extension HTTPConnectionPool.ConnectionFactory { case .http1Only: tlsConfig.applicationProtocols = ["http/1.1"] } + self.clientConfiguration.applyLocalIdentityNIOSSL(to: &tlsConfig, for: self.key.origin) let sslServerHostname = self.key.serverNameIndicator let sslContextFuture = self.sslContextCache.sslContext( @@ -664,10 +665,7 @@ extension HTTPConnectionPool.ConnectionFactory { on: eventLoop, serverNameIndicatorOverride: key.serverNameIndicatorOverride, customVerification: self.clientConfiguration.tlsCustomVerificationNetworkFramework, - localIdentity: self.clientConfiguration.localIdentityNetworkFramework( - forHost: self.key.originHost, - port: self.key.originPort - ) + localIdentity: self.clientConfiguration.localIdentityNetworkFramework(for: self.key.origin) ).map { options -> NIOClientTCPBootstrapProtocol in @@ -711,6 +709,7 @@ extension HTTPConnectionPool.ConnectionFactory { } #endif + self.clientConfiguration.applyLocalIdentityNIOSSL(to: &tlsConfig, for: self.key.origin) let sslContextFuture = sslContextCache.sslContext( tlsConfiguration: tlsConfig, eventLoop: eventLoop, diff --git a/Sources/AsyncHTTPClient/HTTPClient.swift b/Sources/AsyncHTTPClient/HTTPClient.swift index 9e4e1f147..295357e2e 100644 --- a/Sources/AsyncHTTPClient/HTTPClient.swift +++ b/Sources/AsyncHTTPClient/HTTPClient.swift @@ -972,6 +972,43 @@ public final class HTTPClient: Sendable { /// Defaults to `nil` (OS default interface selection). public var localAddress: String? + /// A client identity (certificate chain + private key) to present for mTLS on connections that + /// use NIOSSL: every connection on platforms without Network.framework, connections on + /// Apple platforms whose event loop is not a Network.framework one, and proxied connections + /// everywhere. + public struct NIOSSLClientIdentity: Sendable { + /// The certificate chain, leaf first. + public var certificateChain: [NIOSSLCertificateSource] + + /// The private key matching the leaf certificate. + public var privateKey: NIOSSLPrivateKeySource + + public init(certificateChain: [NIOSSLCertificateSource], privateKey: NIOSSLPrivateKeySource) { + self.certificateChain = certificateChain + self.privateKey = privateKey + } + } + + /// Chooses the client identity to present for mTLS, per origin, on connections that use NIOSSL. + /// + /// This follows the model of `URLSession`'s authentication challenge: the identity is selected + /// for the origin that is actually being connected to, and returning `nil` presents none. A + /// connection is opened per origin, so a redirect to a different host asks the provider again + /// with that host, and an identity meant for the original host is never sent to it. + /// + /// When set, the provider is the only source of the client identity: any + /// ``TLSConfiguration/certificateChain`` or ``TLSConfiguration/privateKey`` in + /// ``tlsConfiguration`` or in a request's own TLS configuration is replaced by its answer (and + /// cleared when it returns `nil`). Setting those directly, without a provider, presents the + /// identity to every origin the client connects to, including redirect targets. + /// + /// The closure receives the host and port of the origin the request targets (an IPv6 literal is + /// passed without its square brackets, and the host is the one named in the URL even when a + /// DNS override is configured). It is called on the connection's event loop each time a + /// connection is opened, so it must be cheap and must not block. Connections are pooled per + /// origin, so a changed answer only applies to connections opened after the change. + public var tlsLocalIdentityProviderNIOSSL: (@Sendable (_ host: String, _ port: Int) -> NIOSSLClientIdentity?)? + /// A method with access to the HTTP/1 connection channel that is called when creating the connection. public var http1_1ConnectionDebugInitializer: (@Sendable (Channel) -> EventLoopFuture)? @@ -1900,3 +1937,22 @@ public struct HTTPClientError: Error, Equatable, CustomStringConvertible { ) public static let httpEndReceivedAfterHeadWith1xx = HTTPClientError(code: .httpEndReceivedAfterHeadWith1xx) } + +extension HTTPClient.Configuration { + /// Replaces the client identity in `tlsConfiguration` with the one the NIOSSL provider chooses for + /// `origin`, if a provider is configured; otherwise leaves it untouched. + /// + /// A connection is bound to a single origin, and redirects to another origin open a new connection + /// to it, so deciding here — rather than once for the whole client — is what keeps an identity from + /// following a redirect to a host it was not meant for. It also overrides an identity carried in a + /// request's own TLS configuration, which redirects preserve. A `nil` origin (unix sockets) is + /// treated as an origin the provider has no identity for. + func applyLocalIdentityNIOSSL(to tlsConfiguration: inout TLSConfiguration, for origin: (host: String, port: Int)?) { + guard let provider = self.tlsLocalIdentityProviderNIOSSL else { + return + } + let identity = origin.flatMap { provider($0.host, $0.port) } + tlsConfiguration.certificateChain = identity?.certificateChain ?? [] + tlsConfiguration.privateKey = identity?.privateKey + } +} diff --git a/Sources/AsyncHTTPClient/NIOTransportServices/TLSConfiguration.swift b/Sources/AsyncHTTPClient/NIOTransportServices/TLSConfiguration.swift index 02f3c4e51..f6c8c0e8c 100644 --- a/Sources/AsyncHTTPClient/NIOTransportServices/TLSConfiguration.swift +++ b/Sources/AsyncHTTPClient/NIOTransportServices/TLSConfiguration.swift @@ -281,21 +281,18 @@ extension TLSConfiguration { } extension HTTPClient.Configuration { - /// The client identity to present on a connection opened to `host`:`port`, if any. + /// The client identity to present on a connection opened to `origin`, if any. /// /// A connection is bound to a single origin, and redirects to another origin open a new connection /// to it, so deciding here — rather than once for the whole client — is what keeps an identity from - /// following a redirect to a host it was not meant for. `nil` host/port (unix sockets) never - /// consult the provider. - func localIdentityNetworkFramework(forHost host: String?, port: Int?) -> SecIdentity? { + /// following a redirect to a host it was not meant for. A `nil` origin (unix sockets) never + /// consults the provider. + func localIdentityNetworkFramework(for origin: (host: String, port: Int)?) -> SecIdentity? { if let provider = self.tlsLocalIdentityProviderNetworkFramework { - guard var host, let port else { + guard let origin else { return nil } - if host.hasPrefix("["), host.hasSuffix("]") { - host = String(host.dropFirst().dropLast()) - } - return provider(host, port) + return provider(origin.host, origin.port) } return self.tlsLocalIdentityNetworkFramework } diff --git a/Tests/AsyncHTTPClientTests/LocalIdentityNIOSSLTests.swift b/Tests/AsyncHTTPClientTests/LocalIdentityNIOSSLTests.swift new file mode 100644 index 000000000..88d0cbc0d --- /dev/null +++ b/Tests/AsyncHTTPClientTests/LocalIdentityNIOSSLTests.swift @@ -0,0 +1,220 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the AsyncHTTPClient open source project +// +// Copyright (c) 2026 Apple Inc. and the AsyncHTTPClient project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of AsyncHTTPClient project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import NIOConcurrencyHelpers +import NIOCore +import NIOHTTP1 +import NIOPosix +import NIOSSL +import XCTest + +@testable import AsyncHTTPClient + +/// Tests for `HTTPClient.Configuration.tlsLocalIdentityProviderNIOSSL` — choosing the mTLS client +/// identity per origin on connections that use NIOSSL. +/// +/// These always run on a `MultiThreadedEventLoopGroup`, which is what selects the NIOSSL backend even +/// on Apple platforms. +final class LocalIdentityNIOSSLTests: XCTestCase { + var clientGroup: EventLoopGroup! + + override func setUp() { + XCTAssertNil(self.clientGroup) + self.clientGroup = MultiThreadedEventLoopGroup(numberOfThreads: 2) + } + + override func tearDown() { + XCTAssertNotNil(self.clientGroup) + XCTAssertNoThrow(try self.clientGroup.syncShutdownGracefully()) + self.clientGroup = nil + } + + private static let identity = HTTPClient.Configuration.NIOSSLClientIdentity( + certificateChain: [.certificate(TestTLS.certificate)], + privateKey: .privateKey(TestTLS.privateKey) + ) + + /// An mTLS server (trusting only `TestTLS.certificate`) reachable as `https://localhost:`. + private func makeClientCertificateRequiringServer() -> HTTPBin { + var serverConfig = TestTLS.serverConfiguration + serverConfig.certificateVerification = .noHostnameVerification + serverConfig.trustRoots = .certificates([TestTLS.certificate]) + return HTTPBin(.http1_1(tlsConfiguration: serverConfig)) + } + + private func makeClient( + configure: (inout HTTPClient.Configuration) -> Void + ) -> HTTPClient { + // The test server is self-signed; the subject here is the *client's* certificate. + var config = HTTPClient.Configuration(certificateVerification: .none).enableFastFailureModeForTesting() + configure(&config) + return HTTPClient(eventLoopGroupProvider: .shared(self.clientGroup), configuration: config) + } + + func testNoProviderAndNoCertificateIsRejectedByTheServer() throws { + // The negative control proving the server genuinely enforces mTLS, so the tests below aren't + // false passes. + let httpBin = self.makeClientCertificateRequiringServer() + let httpClient = self.makeClient { _ in } + defer { + XCTAssertNoThrow(try httpClient.syncShutdown()) + XCTAssertNoThrow(try httpBin.shutdown()) + } + + XCTAssertThrowsError(try httpClient.get(url: "https://localhost:\(httpBin.port)/get").wait()) + } + + func testProviderIsAskedForTheOriginOfEachConnection() throws { + let requestedOrigins = NIOLockedValueBox<[String]>([]) + let httpBin = self.makeClientCertificateRequiringServer() + let httpClient = self.makeClient { + $0.tlsLocalIdentityProviderNIOSSL = { host, port in + requestedOrigins.withLockedValue { $0.append("\(host):\(port)") } + return nil + } + } + defer { + XCTAssertNoThrow(try httpClient.syncShutdown()) + XCTAssertNoThrow(try httpBin.shutdown()) + } + + // The provider has no identity, so the server rejects this; what matters is that it was + // consulted with the origin that was being connected to. + XCTAssertThrowsError(try httpClient.get(url: "https://localhost:\(httpBin.port)/get").wait()) + XCTAssertEqual(requestedOrigins.withLockedValue { $0 }, ["localhost:\(httpBin.port)"]) + } + + func testIdentityFromProviderIsPresentedToTheOriginItIsConfiguredFor() throws { + let httpBin = self.makeClientCertificateRequiringServer() + let httpClient = self.makeClient { + $0.tlsLocalIdentityProviderNIOSSL = { host, _ in + host == "localhost" ? Self.identity : nil + } + } + defer { + XCTAssertNoThrow(try httpClient.syncShutdown()) + XCTAssertNoThrow(try httpBin.shutdown()) + } + + let response = try httpClient.get(url: "https://localhost:\(httpBin.port)/get").wait() + XCTAssertEqual(response.status, .ok) + } + + func testIdentityIsNotPresentedToTheTargetOfARedirectToAnotherHost() throws { + // The identity is meant for 127.0.0.1 only. That server answers with a redirect to + // https://localhost, which demands a client certificate: following the redirect must not hand + // the identity over, so the handshake has to fail. + let redirector = HTTPBin() + let mTLSServer = self.makeClientCertificateRequiringServer() + let httpClient = self.makeClient { + $0.tlsLocalIdentityProviderNIOSSL = { host, _ in + host == "127.0.0.1" ? Self.identity : nil + } + } + defer { + XCTAssertNoThrow(try httpClient.syncShutdown()) + XCTAssertNoThrow(try mTLSServer.shutdown()) + XCTAssertNoThrow(try redirector.shutdown()) + } + + XCTAssertThrowsError( + try httpClient.get( + url: "http://127.0.0.1:\(redirector.port)/redirect/https?port=\(mTLSServer.port)" + ).wait() + ) + } + + func testIdentityIsPresentedToTheTargetOfARedirectWhenItIsTheConfiguredHost() throws { + // Same redirect as above, but the identity is configured for the redirect's destination: the + // positive control proving the failure above comes from the scoping, not from the redirect. + let redirector = HTTPBin() + let mTLSServer = self.makeClientCertificateRequiringServer() + let httpClient = self.makeClient { + $0.tlsLocalIdentityProviderNIOSSL = { host, _ in + host == "localhost" ? Self.identity : nil + } + } + defer { + XCTAssertNoThrow(try httpClient.syncShutdown()) + XCTAssertNoThrow(try mTLSServer.shutdown()) + XCTAssertNoThrow(try redirector.shutdown()) + } + + let response = try httpClient.get( + url: "http://127.0.0.1:\(redirector.port)/redirect/https?port=\(mTLSServer.port)" + ).wait() + XCTAssertEqual(response.status, .ok) + } + + func testProviderOverridesAnUnscopedIdentityInTLSConfiguration() throws { + // `tlsConfiguration` carries the identity for every origin — the leak a provider exists to + // prevent. With a provider set, its answer (here: none for localhost) wins. + let httpBin = self.makeClientCertificateRequiringServer() + let httpClient = self.makeClient { + var tlsConfiguration = TLSConfiguration.makeClientConfiguration() + tlsConfiguration.certificateVerification = .none + tlsConfiguration.certificateChain = Self.identity.certificateChain + tlsConfiguration.privateKey = Self.identity.privateKey + $0.tlsConfiguration = tlsConfiguration + $0.tlsLocalIdentityProviderNIOSSL = { _, _ in nil } + } + defer { + XCTAssertNoThrow(try httpClient.syncShutdown()) + XCTAssertNoThrow(try httpBin.shutdown()) + } + + XCTAssertThrowsError(try httpClient.get(url: "https://localhost:\(httpBin.port)/get").wait()) + } + + func testProviderOverridesAnUnscopedIdentityInARequestsTLSConfiguration() throws { + // Redirects preserve a request's own TLS configuration, so an identity in it would follow the + // redirect too. The provider has to win over that as well. + let httpBin = self.makeClientCertificateRequiringServer() + let httpClient = self.makeClient { + $0.tlsLocalIdentityProviderNIOSSL = { _, _ in nil } + } + defer { + XCTAssertNoThrow(try httpClient.syncShutdown()) + XCTAssertNoThrow(try httpBin.shutdown()) + } + + var requestTLS = TLSConfiguration.makeClientConfiguration() + requestTLS.certificateVerification = .none + requestTLS.certificateChain = Self.identity.certificateChain + requestTLS.privateKey = Self.identity.privateKey + var request = try HTTPClient.Request(url: "https://localhost:\(httpBin.port)/get") + request.tlsConfiguration = requestTLS + + XCTAssertThrowsError(try httpClient.execute(request: request).wait()) + } + + func testUnscopedIdentityInTLSConfigurationStillWorksWithoutAProvider() throws { + // Existing behaviour, unchanged when no provider is configured. + let httpBin = self.makeClientCertificateRequiringServer() + let httpClient = self.makeClient { + var tlsConfiguration = TLSConfiguration.makeClientConfiguration() + tlsConfiguration.certificateVerification = .none + tlsConfiguration.certificateChain = Self.identity.certificateChain + tlsConfiguration.privateKey = Self.identity.privateKey + $0.tlsConfiguration = tlsConfiguration + } + defer { + XCTAssertNoThrow(try httpClient.syncShutdown()) + XCTAssertNoThrow(try httpBin.shutdown()) + } + + let response = try httpClient.get(url: "https://localhost:\(httpBin.port)/get").wait() + XCTAssertEqual(response.status, .ok) + } +} diff --git a/Tests/AsyncHTTPClientTests/LocalIdentityNetworkFrameworkTests.swift b/Tests/AsyncHTTPClientTests/LocalIdentityNetworkFrameworkTests.swift index 6856c524a..cb0268bbf 100644 --- a/Tests/AsyncHTTPClientTests/LocalIdentityNetworkFrameworkTests.swift +++ b/Tests/AsyncHTTPClientTests/LocalIdentityNetworkFrameworkTests.swift @@ -129,17 +129,27 @@ final class LocalIdentityNetworkFrameworkTests: XCTestCase { } func testProviderDoesNotSeeIPv6BracketsOrUnixSockets() { - var config = HTTPClient.Configuration(certificateVerification: .none) + var config = HTTPClient.Configuration() let requestedOrigins = NIOLockedValueBox<[String]>([]) config.tlsLocalIdentityProviderNetworkFramework = { host, port in requestedOrigins.withLockedValue { $0.append("\(host)|\(port)") } return nil } - XCTAssertNil(config.localIdentityNetworkFramework(forHost: "[::1]", port: 8443)) - XCTAssertNil(config.localIdentityNetworkFramework(forHost: "example.com", port: 443)) - XCTAssertNil(config.localIdentityNetworkFramework(forHost: nil, port: nil)) - XCTAssertEqual(requestedOrigins.withLockedValue { $0 }, ["::1|8443", "example.com|443"]) + func key(_ target: ConnectionTarget, sni: String? = nil) -> ConnectionPool.Key { + ConnectionPool.Key(scheme: .https, connectionTarget: target, serverNameIndicatorOverride: sni) + } + + XCTAssertNil(config.localIdentityNetworkFramework(for: key(.init(remoteHost: "::1", port: 8443)).origin)) + XCTAssertNil(config.localIdentityNetworkFramework(for: key(.init(remoteHost: "example.com", port: 443)).origin)) + // A DNS override connects to another address but the origin stays the host the URL named. + XCTAssertNil( + config.localIdentityNetworkFramework( + for: key(.init(remoteHost: "10.0.0.1", port: 443), sni: "example.org").origin + ) + ) + XCTAssertNil(config.localIdentityNetworkFramework(for: key(.unixSocket(path: "/tmp/s")).origin)) + XCTAssertEqual(requestedOrigins.withLockedValue { $0 }, ["::1|8443", "example.com|443", "example.org|443"]) } func testIdentityFromProviderIsPresentedToTheOriginItIsConfiguredFor() throws { From 032b1d28749eacfc20fc25e3a72e4e6f286b55bd Mon Sep 17 00:00:00 2001 From: brennobemoura <37243584+brennobemoura@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:58:21 -0300 Subject: [PATCH 3/4] Test the NIOSSL identity provider inside a proxy tunnel 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 --- .../LocalIdentityNIOSSLTests.swift | 63 ++++++++++++++++++- 1 file changed, 61 insertions(+), 2 deletions(-) diff --git a/Tests/AsyncHTTPClientTests/LocalIdentityNIOSSLTests.swift b/Tests/AsyncHTTPClientTests/LocalIdentityNIOSSLTests.swift index 88d0cbc0d..02180a94c 100644 --- a/Tests/AsyncHTTPClientTests/LocalIdentityNIOSSLTests.swift +++ b/Tests/AsyncHTTPClientTests/LocalIdentityNIOSSLTests.swift @@ -46,11 +46,13 @@ final class LocalIdentityNIOSSLTests: XCTestCase { ) /// An mTLS server (trusting only `TestTLS.certificate`) reachable as `https://localhost:`. - private func makeClientCertificateRequiringServer() -> HTTPBin { + private func makeClientCertificateRequiringServer( + proxy: HTTPBin.Proxy = .none + ) -> HTTPBin { var serverConfig = TestTLS.serverConfiguration serverConfig.certificateVerification = .noHostnameVerification serverConfig.trustRoots = .certificates([TestTLS.certificate]) - return HTTPBin(.http1_1(tlsConfiguration: serverConfig)) + return HTTPBin(.http1_1(tlsConfiguration: serverConfig), proxy: proxy) } private func makeClient( @@ -217,4 +219,61 @@ final class LocalIdentityNIOSSLTests: XCTestCase { let response = try httpClient.get(url: "https://localhost:\(httpBin.port)/get").wait() XCTAssertEqual(response.status, .ok) } + + // MARK: - TLS inside a proxy tunnel + + func testProxyTunnelAsksTheProviderForTheDestinationOrigin() throws { + // The simulated proxy answers CONNECT and then terminates TLS itself, demanding a client + // certificate: the same port plays proxy and destination. + let proxyAndDestination = self.makeClientCertificateRequiringServer(proxy: .simulate(authorization: nil)) + let requestedOrigins = NIOLockedValueBox<[String]>([]) + let httpClient = self.makeClient { + $0.proxy = .server(host: "localhost", port: proxyAndDestination.port) + $0.tlsLocalIdentityProviderNIOSSL = { host, port in + requestedOrigins.withLockedValue { $0.append("\(host):\(port)") } + return nil + } + } + defer { + XCTAssertNoThrow(try httpClient.syncShutdown()) + XCTAssertNoThrow(try proxyAndDestination.shutdown()) + } + + XCTAssertThrowsError(try httpClient.get(url: "https://test/ok").wait()) + // The origin is the one the request named, not the proxy it is tunnelled through. + XCTAssertEqual(requestedOrigins.withLockedValue { $0 }, ["test:443"]) + } + + func testProxyTunnelPresentsTheIdentityTheProviderChoosesForTheDestination() throws { + let proxyAndDestination = self.makeClientCertificateRequiringServer(proxy: .simulate(authorization: nil)) + let httpClient = self.makeClient { + $0.proxy = .server(host: "localhost", port: proxyAndDestination.port) + $0.tlsLocalIdentityProviderNIOSSL = { host, _ in + host == "test" ? Self.identity : nil + } + } + defer { + XCTAssertNoThrow(try httpClient.syncShutdown()) + XCTAssertNoThrow(try proxyAndDestination.shutdown()) + } + + let response = try httpClient.get(url: "https://test/ok").wait() + XCTAssertEqual(response.status, .ok) + } + + func testProxyTunnelDoesNotPresentTheIdentityToAnotherDestination() throws { + let proxyAndDestination = self.makeClientCertificateRequiringServer(proxy: .simulate(authorization: nil)) + let httpClient = self.makeClient { + $0.proxy = .server(host: "localhost", port: proxyAndDestination.port) + $0.tlsLocalIdentityProviderNIOSSL = { host, _ in + host == "somewhere-else" ? Self.identity : nil + } + } + defer { + XCTAssertNoThrow(try httpClient.syncShutdown()) + XCTAssertNoThrow(try proxyAndDestination.shutdown()) + } + + XCTAssertThrowsError(try httpClient.get(url: "https://test/ok").wait()) + } } From 8a0b89b53faa2a53d49eb96a73c63e8c66387c8d Mon Sep 17 00:00:00 2001 From: brennobemoura <37243584+brennobemoura@users.noreply.github.com> Date: Wed, 7 Oct 2026 20:20:01 -0300 Subject: [PATCH 4/4] Fix DocC symbol-link resolution failure in tlsLocalIdentityProviderNIOSSL'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 --- Sources/AsyncHTTPClient/HTTPClient.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/AsyncHTTPClient/HTTPClient.swift b/Sources/AsyncHTTPClient/HTTPClient.swift index 295357e2e..9cead67ae 100644 --- a/Sources/AsyncHTTPClient/HTTPClient.swift +++ b/Sources/AsyncHTTPClient/HTTPClient.swift @@ -997,7 +997,7 @@ public final class HTTPClient: Sendable { /// with that host, and an identity meant for the original host is never sent to it. /// /// When set, the provider is the only source of the client identity: any - /// ``TLSConfiguration/certificateChain`` or ``TLSConfiguration/privateKey`` in + /// `TLSConfiguration.certificateChain` or `TLSConfiguration.privateKey` in /// ``tlsConfiguration`` or in a request's own TLS configuration is replaced by its answer (and /// cleared when it returns `nil`). Setting those directly, without a provider, presents the /// identity to every origin the client connects to, including redirect targets.