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 8c332cc77..ff5241bf9 100644 --- a/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift +++ b/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift @@ -664,7 +664,10 @@ extension HTTPConnectionPool.ConnectionFactory { on: eventLoop, serverNameIndicatorOverride: key.serverNameIndicatorOverride, customVerification: self.clientConfiguration.tlsCustomVerificationNetworkFramework, - 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 39f61fd8d..9e4e1f147 100644 --- a/Sources/AsyncHTTPClient/HTTPClient.swift +++ b/Sources/AsyncHTTPClient/HTTPClient.swift @@ -1028,7 +1028,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( @@ -1054,6 +1076,7 @@ public final class HTTPClient: Sendable { #if canImport(Network) self.tlsCustomVerificationNetworkFramework = nil self.tlsLocalIdentityNetworkFramework = nil + self.tlsLocalIdentityProviderNetworkFramework = nil #endif } diff --git a/Sources/AsyncHTTPClient/NIOTransportServices/TLSConfiguration.swift b/Sources/AsyncHTTPClient/NIOTransportServices/TLSConfiguration.swift index 1dc1474c9..02f3c4e51 100644 --- a/Sources/AsyncHTTPClient/NIOTransportServices/TLSConfiguration.swift +++ b/Sources/AsyncHTTPClient/NIOTransportServices/TLSConfiguration.swift @@ -280,6 +280,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 7e9d3a4be..36cc9aec7 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