From dacc79225f9cb14c272d67ca56959b6f97ea8941 Mon Sep 17 00:00:00 2001 From: brennobemoura <37243584+brennobemoura@users.noreply.github.com> Date: Tue, 8 Sep 2026 18:34:16 -0300 Subject: [PATCH 1/5] Add mTLS client-identity hook for Network.framework connections Exposes HTTPClient.Configuration.tlsLocalIdentityNetworkFramework: a thin passthrough into Network.framework's sec_protocol_options_set_local_identity, for direct (non-proxied) connections on Apple platforms. tlsConfiguration.certificateChain and .privateKey (the NIOSSL-shaped mTLS config) remain unsupported on this backend, same as before -- there's no public API to build a SecIdentity from raw bytes without a Keychain round-trip, so this hook takes an already-built SecIdentity rather than AsyncHTTPClient performing that round-trip itself. Tests cover both that the client certificate is actually presented to a server that requires one, and the negative control (connection rejected without it). Synthesizing a Keychain-backed SecIdentity inside an unsigned `swift test` process is itself unreliable -- the positive test skips rather than flakes when that round-trip can't complete, same limitation RequestDL's own RawBytesIdentityBuilder test suite already works around by only unit-testing its DER-parsing halves. Co-Authored-By: Claude Sonnet 5 --- .../HTTPConnectionPool+Factory.swift | 3 +- Sources/AsyncHTTPClient/HTTPClient.swift | 23 ++ .../TLSConfiguration.swift | 37 ++- .../HTTPClientTestUtils.swift | 16 ++ .../LocalIdentityNetworkFrameworkTests.swift | 260 ++++++++++++++++++ 5 files changed, 335 insertions(+), 4 deletions(-) create mode 100644 Tests/AsyncHTTPClientTests/LocalIdentityNetworkFrameworkTests.swift diff --git a/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift b/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift index f26fc8c08..7bc67ecec 100644 --- a/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift +++ b/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift @@ -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 diff --git a/Sources/AsyncHTTPClient/HTTPClient.swift b/Sources/AsyncHTTPClient/HTTPClient.swift index cc8792497..610b7329c 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) @@ -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, @@ -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( diff --git a/Sources/AsyncHTTPClient/NIOTransportServices/TLSConfiguration.swift b/Sources/AsyncHTTPClient/NIOTransportServices/TLSConfiguration.swift index ad3e65074..8bf9fd124 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,12 @@ extension TLSConfiguration { } } +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/HTTPClientTestUtils.swift b/Tests/AsyncHTTPClientTests/HTTPClientTestUtils.swift index ea80cee6e..705e37a51 100644 --- a/Tests/AsyncHTTPClientTests/HTTPClientTestUtils.swift +++ b/Tests/AsyncHTTPClientTests/HTTPClientTestUtils.swift @@ -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) diff --git a/Tests/AsyncHTTPClientTests/LocalIdentityNetworkFrameworkTests.swift b/Tests/AsyncHTTPClientTests/LocalIdentityNetworkFrameworkTests.swift new file mode 100644 index 000000000..ac22f52e8 --- /dev/null +++ b/Tests/AsyncHTTPClientTests/LocalIdentityNetworkFrameworkTests.swift @@ -0,0 +1,260 @@ +//===----------------------------------------------------------------------===// +// +// 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 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 } + + // 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) } + + // 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() + config.tlsLocalIdentityNetworkFramework = handle.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().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()) + } + #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 + } + + enum Error: Swift.Error { + case keychainOperationFailed(OSStatus, operation: String) + case identityLookupReturnedWrongType + case malformedDER + } + + static func makeIdentity(certificateDER: Data, privateKeyPKCS8DER: Data) throws -> Handle { + guard let certificate = SecCertificateCreateWithData(nil, certificateDER as CFData) else { + throw Error.malformedDER + } + 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 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)" + ) + + // 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") + } + + return Handle(identity: matchingIdentity, label: label) + } + + 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) + } + } + + /// 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 + } + + 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) + } + } +} + +/// 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 + + init(_ bytes: [UInt8]) { + self.bytes = bytes + } + + mutating func readSequence() throws -> [UInt8] { + try read(tag: 0x30) + } + + 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.. Date: Wed, 7 Oct 2026 14:38:18 -0300 Subject: [PATCH 2/5] 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 7bc67ecec..c573a534d 100644 --- a/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift +++ b/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift @@ -592,7 +592,10 @@ extension HTTPConnectionPool.ConnectionFactory { let bootstrapFuture = tlsConfig.getNWProtocolTLSOptions( on: eventLoop, serverNameIndicatorOverride: key.serverNameIndicatorOverride, - 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 610b7329c..cc98f6d2d 100644 --- a/Sources/AsyncHTTPClient/HTTPClient.swift +++ b/Sources/AsyncHTTPClient/HTTPClient.swift @@ -962,7 +962,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( @@ -986,6 +1008,7 @@ public final class HTTPClient: Sendable { self.localAddress = nil #if canImport(Network) self.tlsLocalIdentityNetworkFramework = nil + self.tlsLocalIdentityProviderNetworkFramework = nil #endif } diff --git a/Sources/AsyncHTTPClient/NIOTransportServices/TLSConfiguration.swift b/Sources/AsyncHTTPClient/NIOTransportServices/TLSConfiguration.swift index 8bf9fd124..043d41977 100644 --- a/Sources/AsyncHTTPClient/NIOTransportServices/TLSConfiguration.swift +++ b/Sources/AsyncHTTPClient/NIOTransportServices/TLSConfiguration.swift @@ -246,6 +246,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 705e37a51..ea80cee6e 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 12eb2b3ae7496121b303f699959413dbefbca142 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 3/5] 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 c573a534d..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( @@ -592,10 +593,7 @@ extension HTTPConnectionPool.ConnectionFactory { let bootstrapFuture = tlsConfig.getNWProtocolTLSOptions( on: eventLoop, serverNameIndicatorOverride: key.serverNameIndicatorOverride, - localIdentity: self.clientConfiguration.localIdentityNetworkFramework( - forHost: self.key.originHost, - port: self.key.originPort - ) + localIdentity: self.clientConfiguration.localIdentityNetworkFramework(for: self.key.origin) ).map { options -> NIOClientTCPBootstrapProtocol in @@ -639,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 cc98f6d2d..9a0842d9a 100644 --- a/Sources/AsyncHTTPClient/HTTPClient.swift +++ b/Sources/AsyncHTTPClient/HTTPClient.swift @@ -938,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)? @@ -1790,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 043d41977..9190dc1b4 100644 --- a/Sources/AsyncHTTPClient/NIOTransportServices/TLSConfiguration.swift +++ b/Sources/AsyncHTTPClient/NIOTransportServices/TLSConfiguration.swift @@ -247,21 +247,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 3ab528232f77f7d37061875f376b8fd5647a3a62 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 4/5] 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 cac7bb0f8a1da856768994d5b1d70f757ef1867f 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 5/5] 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 9a0842d9a..7f54edf19 100644 --- a/Sources/AsyncHTTPClient/HTTPClient.swift +++ b/Sources/AsyncHTTPClient/HTTPClient.swift @@ -963,7 +963,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.