Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions Sources/AsyncHTTPClient/ConnectionPool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -591,7 +591,11 @@ extension HTTPConnectionPool.ConnectionFactory {
let localAddr = self.key.localAddress
let bootstrapFuture = tlsConfig.getNWProtocolTLSOptions(
on: eventLoop,
serverNameIndicatorOverride: key.serverNameIndicatorOverride
serverNameIndicatorOverride: key.serverNameIndicatorOverride,
localIdentity: self.clientConfiguration.localIdentityNetworkFramework(
forHost: self.key.originHost,
port: self.key.originPort
)
).map {
options -> NIOClientTCPBootstrapProtocol in

Expand Down
46 changes: 46 additions & 0 deletions Sources/AsyncHTTPClient/HTTPClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -945,6 +950,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,
Expand All @@ -964,6 +1006,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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<NWProtocolTLS.Options> {
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)
Expand All @@ -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 = """
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -223,4 +246,33 @@ 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

var description: String {
"sec_identity_create(_:) returned nil for the SecIdentity passed as tlsLocalIdentityNetworkFramework."
}
}

#endif
Loading
Loading