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..9cead67ae 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..02180a94c --- /dev/null +++ b/Tests/AsyncHTTPClientTests/LocalIdentityNIOSSLTests.swift @@ -0,0 +1,279 @@ +//===----------------------------------------------------------------------===// +// +// 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( + proxy: HTTPBin.Proxy = .none + ) -> HTTPBin { + var serverConfig = TestTLS.serverConfiguration + serverConfig.certificateVerification = .noHostnameVerification + serverConfig.trustRoots = .certificates([TestTLS.certificate]) + return HTTPBin(.http1_1(tlsConfiguration: serverConfig), proxy: proxy) + } + + 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) + } + + // 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()) + } +} 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 {