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
Original file line number Diff line number Diff line change
Expand Up @@ -591,7 +591,8 @@ extension HTTPConnectionPool.ConnectionFactory {
let localAddr = self.key.localAddress
let bootstrapFuture = tlsConfig.getNWProtocolTLSOptions(
on: eventLoop,
serverNameIndicatorOverride: key.serverNameIndicatorOverride
serverNameIndicatorOverride: key.serverNameIndicatorOverride,
localIdentity: self.clientConfiguration.tlsLocalIdentityNetworkFramework
).map {
options -> NIOClientTCPBootstrapProtocol in

Expand Down
23 changes: 23 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,21 @@ 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.
public var tlsLocalIdentityNetworkFramework: SecIdentity?
#endif

public init(
tlsConfiguration: TLSConfiguration? = nil,
redirectConfiguration: RedirectConfiguration? = nil,
Expand All @@ -964,6 +984,9 @@ public final class HTTPClient: Sendable {
self.networkFrameworkWaitForConnectivity = true
self.enableMultipath = false
self.localAddress = nil
#if canImport(Network)
self.tlsLocalIdentityNetworkFramework = 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,12 @@ extension TLSConfiguration {
}
}

enum NWLocalIdentityError: Error, CustomStringConvertible {
case identityCreationFailed

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

#endif
16 changes: 16 additions & 0 deletions Tests/AsyncHTTPClientTests/HTTPClientTestUtils.swift
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,22 @@ 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)
Expand Down
Loading
Loading