diff --git a/Sources/AsyncHTTPClient/ConnectionPool.swift b/Sources/AsyncHTTPClient/ConnectionPool.swift index a659df27b..47119579b 100644 --- a/Sources/AsyncHTTPClient/ConnectionPool.swift +++ b/Sources/AsyncHTTPClient/ConnectionPool.swift @@ -103,6 +103,26 @@ extension DeconstructedURL { } } +extension ConnectionPool.Key { + /// 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 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) + } +} + extension ConnectionPool.Key { init( url: DeconstructedURL, diff --git a/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift b/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift index f26fc8c08..4c0483b0f 100644 --- a/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift +++ b/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift @@ -396,6 +396,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( @@ -591,7 +592,8 @@ extension HTTPConnectionPool.ConnectionFactory { let localAddr = self.key.localAddress let bootstrapFuture = tlsConfig.getNWProtocolTLSOptions( on: eventLoop, - serverNameIndicatorOverride: key.serverNameIndicatorOverride + serverNameIndicatorOverride: key.serverNameIndicatorOverride, + localIdentity: self.clientConfiguration.localIdentityNetworkFramework(for: self.key.origin) ).map { options -> NIOClientTCPBootstrapProtocol in @@ -635,6 +637,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 cc8792497..7f54edf19 100644 --- a/Sources/AsyncHTTPClient/HTTPClient.swift +++ b/Sources/AsyncHTTPClient/HTTPClient.swift @@ -26,6 +26,11 @@ import Tracing #if canImport(Network) import NIOTransportServices +import Security + +// `SecIdentity` is an opaque reference to an immutable, already-looked-up Keychain item — safe to +// hand across threads, but the Security framework overlay doesn't mark it `Sendable` itself. +extension SecIdentity: @retroactive @unchecked Sendable {} #endif #if canImport(FoundationEssentials) @@ -933,6 +938,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)? @@ -945,6 +987,43 @@ public final class HTTPClient: Sendable { /// Configuration how distributed traces are created and handled. public var tracing: TracingConfiguration = .init() + #if canImport(Network) + /// A client identity (certificate + private key) to present for mTLS on direct (non-proxied) + /// connections that use Network.framework instead of NIOSSL. `tlsConfiguration.certificateChain` + /// and `.privateKey` are the equivalent for the NIOSSL backend used everywhere else (including + /// every proxied connection regardless of platform) — they are **not** supported here, and + /// setting them alongside a `nil` value here still fails at connection time. + /// + /// There is no public API on Apple platforms to build a `SecIdentity` from raw certificate/key + /// bytes purely in memory — only a Keychain round-trip (`SecItemAdd` the certificate and key, + /// 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( tlsConfiguration: TLSConfiguration? = nil, redirectConfiguration: RedirectConfiguration? = nil, @@ -964,6 +1043,10 @@ public final class HTTPClient: Sendable { self.networkFrameworkWaitForConnectivity = true self.enableMultipath = false self.localAddress = nil + #if canImport(Network) + self.tlsLocalIdentityNetworkFramework = nil + self.tlsLocalIdentityProviderNetworkFramework = nil + #endif } public init( @@ -1744,3 +1827,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 ad3e65074..9190dc1b4 100644 --- a/Sources/AsyncHTTPClient/NIOTransportServices/TLSConfiguration.swift +++ b/Sources/AsyncHTTPClient/NIOTransportServices/TLSConfiguration.swift @@ -70,15 +70,21 @@ extension TLSConfiguration { /// create NWProtocolTLS.Options for use with NIOTransportServices from the NIOSSL TLSConfiguration /// /// - Parameter eventLoop: EventLoop to wait for creation of options on + /// - Parameter localIdentity: A client identity (certificate + private key) to present for mTLS — + /// see ``HTTPClient/Configuration/tlsLocalIdentityNetworkFramework``. /// - Returns: Future holding NWProtocolTLS Options func getNWProtocolTLSOptions( on eventLoop: EventLoop, - serverNameIndicatorOverride: String? + serverNameIndicatorOverride: String?, + localIdentity: SecIdentity? = nil ) -> EventLoopFuture { let promise = eventLoop.makePromise(of: NWProtocolTLS.Options.self) Self.tlsDispatchQueue.async { do { - let options = try self.getNWProtocolTLSOptions(serverNameIndicatorOverride: serverNameIndicatorOverride) + let options = try self.getNWProtocolTLSOptions( + serverNameIndicatorOverride: serverNameIndicatorOverride, + localIdentity: localIdentity + ) promise.succeed(options) } catch { promise.fail(error) @@ -89,8 +95,13 @@ extension TLSConfiguration { /// create NWProtocolTLS.Options for use with NIOTransportServices from the NIOSSL TLSConfiguration /// + /// - Parameter localIdentity: A client identity (certificate + private key) to present for mTLS — + /// see ``HTTPClient/Configuration/tlsLocalIdentityNetworkFramework``. /// - Returns: Equivalent NWProtocolTLS Options - func getNWProtocolTLSOptions(serverNameIndicatorOverride: String?) throws -> NWProtocolTLS.Options { + func getNWProtocolTLSOptions( + serverNameIndicatorOverride: String?, + localIdentity: SecIdentity? = nil + ) throws -> NWProtocolTLS.Options { let options = NWProtocolTLS.Options() let useMTELGExplainer = """ @@ -159,6 +170,18 @@ extension TLSConfiguration { preconditionFailure("TLSConfiguration.privateKey is not supported. \(useMTELGExplainer)") } + // local identity (mTLS) — the Network.framework equivalent of certificateChain/privateKey + // above, which this backend doesn't support directly (see HTTPClient.Configuration's + // tlsLocalIdentityNetworkFramework doc comment for why: there's no way to build a SecIdentity + // from raw bytes without a Keychain round-trip, which is the caller's responsibility, not + // AsyncHTTPClient's). + if let localIdentity { + guard let identity = sec_identity_create(localIdentity) else { + throw NWLocalIdentityError.identityCreationFailed + } + sec_protocol_options_set_local_identity(options.securityProtocolOptions, identity) + } + // renegotiation support key is unsupported // trust roots @@ -223,4 +246,30 @@ extension TLSConfiguration { } } +extension HTTPClient.Configuration { + /// 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. A `nil` origin (unix sockets) never + /// consults the provider. + func localIdentityNetworkFramework(for origin: (host: String, port: Int)?) -> SecIdentity? { + if let provider = self.tlsLocalIdentityProviderNetworkFramework { + guard let origin else { + return nil + } + return provider(origin.host, origin.port) + } + return self.tlsLocalIdentityNetworkFramework + } +} + +enum NWLocalIdentityError: Error, CustomStringConvertible { + case identityCreationFailed + + var description: String { + "sec_identity_create(_:) returned nil for the SecIdentity passed as tlsLocalIdentityNetworkFramework." + } +} + #endif 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 new file mode 100644 index 000000000..cb0268bbf --- /dev/null +++ b/Tests/AsyncHTTPClientTests/LocalIdentityNetworkFrameworkTests.swift @@ -0,0 +1,319 @@ +//===----------------------------------------------------------------------===// +// +// 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 NIOSSL +import XCTest + +@testable import AsyncHTTPClient + +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif + +#if canImport(Network) +import Network +import Security +#endif + +/// Tests for `HTTPClient.Configuration.tlsLocalIdentityNetworkFramework` — the mTLS client-identity +/// hook for direct (non-proxied) connections that use Network.framework instead of NIOSSL. +final class LocalIdentityNetworkFrameworkTests: XCTestCase { + var clientGroup: EventLoopGroup! + + override func setUp() { + XCTAssertNil(self.clientGroup) + self.clientGroup = getDefaultEventLoopGroup(numberOfThreads: 3) + } + + override func tearDown() { + XCTAssertNotNil(self.clientGroup) + XCTAssertNoThrow(try self.clientGroup.syncShutdownGracefully()) + self.clientGroup = nil + } + + #if canImport(Network) + func testClientCertificateIsPresentedOverNetworkFramework() throws { + guard isTestingNIOTS() else { return } + + 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). + var serverConfig = TestTLS.serverConfiguration + serverConfig.certificateVerification = .noHostnameVerification + serverConfig.trustRoots = .certificates([TestTLS.certificate]) + + 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) + defer { + XCTAssertNoThrow(try httpClient.syncShutdown()) + XCTAssertNoThrow(try httpBin.shutdown()) + } + + // If the client failed to present its certificate, the server would reject the handshake + // and this would throw instead of succeeding. + XCTAssertNoThrow(try httpClient.get(url: "https://localhost:\(httpBin.port)/get").wait()) + } + + func testConnectionFailsWithoutClientCertificateWhenServerRequiresOne() throws { + guard isTestingNIOTS() else { return } + + var serverConfig = TestTLS.serverConfiguration + serverConfig.certificateVerification = .noHostnameVerification + serverConfig.trustRoots = .certificates([TestTLS.certificate]) + + // 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(certificateVerification: .none).enableFastFailureModeForTesting() + + let httpBin = HTTPBin(.http1_1(tlsConfiguration: serverConfig)) + let httpClient = HTTPClient(eventLoopGroupProvider: .shared(self.clientGroup), configuration: config) + defer { + XCTAssertNoThrow(try httpClient.syncShutdown()) + XCTAssertNoThrow(try httpBin.shutdown()) + } + + XCTAssertThrowsError(try httpClient.get(url: "https://localhost:\(httpBin.port)/get").wait()) + } + + // MARK: - Per-origin identity (tlsLocalIdentityProviderNetworkFramework) + + /// 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)) + } + + 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 httpBin = self.makeClientCertificateRequiringServer() + let httpClient = HTTPClient(eventLoopGroupProvider: .shared(self.clientGroup), configuration: config) + defer { + XCTAssertNoThrow(try httpClient.syncShutdown()) + XCTAssertNoThrow(try httpBin.shutdown()) + } + + // 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)"]) + } + + func testProviderDoesNotSeeIPv6BracketsOrUnixSockets() { + var config = HTTPClient.Configuration() + let requestedOrigins = NIOLockedValueBox<[String]>([]) + config.tlsLocalIdentityProviderNetworkFramework = { host, port in + requestedOrigins.withLockedValue { $0.append("\(host)|\(port)") } + return nil + } + + 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 { + 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()) + } + + XCTAssertNoThrow(try httpClient.get(url: "https://localhost:\(httpBin.port)/get").wait()) + } + + 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() + ) + } + + 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()) + } + + let response = try httpClient.get( + url: "http://127.0.0.1:\(redirector.port)/redirect/https?port=\(mTLSServer.port)" + ).wait() + XCTAssertEqual(response.status, .ok) + } + #endif +} + +#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 + } + + /// `TestTLS.certificate` + `TestTLS.key`, exported as PKCS#12 with the password `"test"` + /// (3DES/SHA-1, which is what `SecPKCS12Import` accepts on every supported OS version). + private static let pkcs12Base64 = """ + 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 + """ + + static func makeIdentity() throws -> 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") + } + + 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