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] 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..