From cad5ca2c7f9ad5291b00174ce1e0e011d9010385 Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Fri, 5 Dec 2025 12:45:05 +0000 Subject: [PATCH 01/11] Add support for specifying a custom client certificate verification callback Motivation: `NIOSSL` supports specifying a custom verification callback for client certificate verification. When such a callback is provided to `NIOSSLServerHandler`, it will use the callback to _replace_ its default client certificate verification logic, and surface the peer's validated chain of trust (derived and returned from the custom callback). This can be useful for mTLS implementations. `NIOHTTPServer` currently does not support propagating a custom verification callback to `NIOSSLServerHandler`. This change adds support for that and also surfaces the peer's validated certificate chain that then becomes available as a result. Modifications: - Updated `HTTPServerConfiguration` to also include a `customCertificateVerificationCallback` argument for the `mTLS` and `reloadingMTLS` cases of `TransportSecurity`. - Note: This exposes `NIO` types at the `HTTPServerConfiguration` level (in particular, the arguments of the callback are [`NIOSSLCertificate`] and an `EventLoopPromise` that must be fulfilled within the callback). We currently do not have a `NIOHTTPServer`-specific configuration, so let us discuss in the comments what the best way to allow users to specify a custom verification callback would be. - Updated the `serveSecureUpgrade` method in `NIOHTTPServer` to (1) propagate the custom verification callback into the underlying `NIOSSLServerHandler`, (2) extract the peer certificate chain `EventLoopFuture` per connection, and expose that future in a new type in `NIOHTTPServer` named `Context`. - The `Context` type is accessed from a task-local property `context`. Users can await the result of the promise from their route handlers by calling `NIOHTTPServer.context.peerCertificateChain()`. - The name of this type, its properties, the task-local approach, etc. are all very much open for discussion. - Added end-to-end tests for this functionality for both HTTP/1.1 and HTTP2. - Other changes: - Added `NIOSSL+X509` containing some convenience conversions between `NIOSSL` and `X509` types. - Added documentation to the `TransportSecurity` methods as they were missing. Result: Users can now specify a custom verification callback and access the peer's validated certificate chain from the request handler. --- Package.swift | 2 +- .../HTTPServer/HTTPServerConfiguration.swift | 81 +++++++-- Sources/HTTPServer/NIOHTTPServer.swift | 160 ++++++++--------- Sources/HTTPServer/NIOSSL+X509.swift | 57 ++++++ Tests/HTTPServerTests/HTTPServerTests.swift | 57 +++--- .../HTTPServerTests/NIOHTTPServerTests.swift | 163 +++++++++++++++++- .../Utilities/Certificates.swift | 64 +++++++ Tests/HTTPServerTests/Utilities/Client.swift | 79 +++++++++ .../Utilities/SocketAddress.swift | 21 +++ 9 files changed, 566 insertions(+), 118 deletions(-) create mode 100644 Sources/HTTPServer/NIOSSL+X509.swift create mode 100644 Tests/HTTPServerTests/Utilities/Certificates.swift create mode 100644 Tests/HTTPServerTests/Utilities/Client.swift create mode 100644 Tests/HTTPServerTests/Utilities/SocketAddress.swift diff --git a/Package.swift b/Package.swift index a78325e..8113da2 100644 --- a/Package.swift +++ b/Package.swift @@ -24,7 +24,7 @@ let package = Package( .package(url: "https://github.com/apple/swift-collections.git", from: "1.0.4"), .package(url: "https://github.com/apple/swift-http-types.git", from: "1.0.0"), .package(url: "https://github.com/apple/swift-distributed-tracing.git", from: "1.0.0"), - .package(url: "https://github.com/apple/swift-certificates.git", from: "1.0.4"), + .package(url: "https://github.com/apple/swift-certificates.git", from: "1.16.0"), .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"), .package(url: "https://github.com/apple/swift-nio.git", from: "2.0.0"), .package(url: "https://github.com/apple/swift-nio-ssl.git", from: "2.0.0"), diff --git a/Sources/HTTPServer/HTTPServerConfiguration.swift b/Sources/HTTPServer/HTTPServerConfiguration.swift index f2fad3f..aae2b10 100644 --- a/Sources/HTTPServer/HTTPServerConfiguration.swift +++ b/Sources/HTTPServer/HTTPServerConfiguration.swift @@ -11,9 +11,10 @@ // //===----------------------------------------------------------------------===// -public import X509 public import NIOCertificateReloading -import NIOSSL +public import NIOCore +public import NIOSSL +public import X509 /// Configuration settings for the HTTP server. /// @@ -53,6 +54,26 @@ public struct HTTPServerConfiguration: Sendable { /// Provides options for running the server with or without TLS encryption. /// When using TLS, you must either provide a certificate chain and private key, or a `CertificateReloader`. public struct TransportSecurity: Sendable { + /// A callback that replaces `NIOSSL`'s default certificate verification with custom verification logic. + /// + /// This is just a `Sendable` version of `NIOSSLCustomVerificationCallbackWithMetadata`. + /// + /// ## Usage + /// + /// The callback receives: + /// - **certificates**: The certificates presented by the peer. You are responsible for building and validating + /// a chain of trust from these certificates. + /// - **promise**: A promise that must be completed. Call `promise.succeed(...)` with the subset of certificates + /// that formed the validated chain of trust, or `promise.fail()` if verification fails. + /// + /// - Warning: This callback completely replaces NIOSSL's certificate verification logic and must be used with + /// caution. + public typealias CustomCertificateVerificationCallback = + @Sendable ( + [NIOSSLCertificate], + EventLoopPromise + ) -> Void + enum Backing { case plaintext case tls( @@ -63,11 +84,13 @@ public struct HTTPServerConfiguration: Sendable { case mTLS( certificateChain: [Certificate], privateKey: Certificate.PrivateKey, - trustRoots: [Certificate]? + trustRoots: [Certificate]?, + customCertificateVerificationCallback: CustomCertificateVerificationCallback? = nil ) case reloadingMTLS( certificateReloader: any CertificateReloader, - trustRoots: [Certificate]? + trustRoots: [Certificate]?, + customCertificateVerificationCallback: CustomCertificateVerificationCallback? = nil ) } @@ -75,6 +98,10 @@ public struct HTTPServerConfiguration: Sendable { public static let plaintext: Self = Self(backing: .plaintext) + /// Enables TLS. + /// - Parameters: + /// - certificateChain: The certificate chain to present during negotiation. + /// - privateKey: The private key corresponding to the leaf certificate in `certificateChain`. public static func tls( certificateChain: [Certificate], privateKey: Certificate.PrivateKey @@ -87,32 +114,60 @@ public struct HTTPServerConfiguration: Sendable { ) } + /// Enables TLS with automatic certificate reloading. + /// - Parameters: + /// - certificateReloader: The certificate reloader instance. public static func tls(certificateReloader: any CertificateReloader) throws -> Self { Self(backing: .reloadingTLS(certificateReloader: certificateReloader)) } + /// Enables mTLS. Optionally provide a custom verification callback to override the default verification logic + /// used to verify client certificates, and control the derivation of a validated chain of trust from the + /// certificates presented by the peer. + /// + /// - Parameters: + /// - certificateChain: The certificate chain to present during negotiation. + /// - privateKey: The private key corresponding to the leaf certificate in `certificateChain`. + /// - trustRoots: The root certificates to trust when verifying client certificates. + /// - customCertificateVerificationCallback: A custom certificate verification callback. This will override + /// NIOSSL's default certificate verification logic. public static func mTLS( certificateChain: [Certificate], privateKey: Certificate.PrivateKey, - trustRoots: [Certificate]? + trustRoots: [Certificate]?, + customCertificateVerificationCallback: CustomCertificateVerificationCallback? = nil ) -> Self { Self( backing: .mTLS( certificateChain: certificateChain, privateKey: privateKey, - trustRoots: trustRoots + trustRoots: trustRoots, + customCertificateVerificationCallback: customCertificateVerificationCallback ) ) } + /// Enables mTLS with certificate reloading. Optionally provide a custom verification callback to override the default verification logic + /// used to verify client certificates, and control the derivation of a validated chain of trust from the + /// certificates presented by the peer. + /// + /// - Parameters: + /// - certificateReloader: The certificate reloader instance. + /// - trustRoots: The root certificates to trust when verifying client certificates. + /// - customCertificateVerificationCallback: A custom certificate verification callback. This will override + /// NIOSSL's default certificate verification logic. public static func mTLS( certificateReloader: any CertificateReloader, - trustRoots: [Certificate]? + trustRoots: [Certificate]?, + customCertificateVerificationCallback: CustomCertificateVerificationCallback? = nil ) throws -> Self { - Self(backing: .reloadingMTLS( - certificateReloader: certificateReloader, - trustRoots: trustRoots - )) + Self( + backing: .reloadingMTLS( + certificateReloader: certificateReloader, + trustRoots: trustRoots, + customCertificateVerificationCallback: customCertificateVerificationCallback + ) + ) } } @@ -148,7 +203,7 @@ public struct HTTPServerConfiguration: Sendable { maxConcurrentStreams: nil ) } - } + } /// Configuration for the backpressure strategy to use when reading requests and writing back responses. public struct BackPressureStrategy: Sendable { @@ -187,7 +242,7 @@ public struct HTTPServerConfiguration: Sendable { /// Create a new configuration. /// - Parameters: /// - bindTarget: A ``BindTarget``. - /// - tlsConfiguration: A ``TLSConfiguration``. Defaults to ``TLSConfiguration/insecure``. + /// - transportSecurity: A ``TransportSecurity``. Defaults to ``TransportSecurity/plaintext``. /// - backpressureStrategy: A ``BackPressureStrategy``. /// Defaults to ``BackPressureStrategy/watermark(low:high:)`` with a low watermark of 2 and a high of 10. /// - http2: A ``HTTP2``. Defaults to ``HTTP2/defaults``. diff --git a/Sources/HTTPServer/NIOHTTPServer.swift b/Sources/HTTPServer/NIOHTTPServer.swift index 43f3f6e..955c275 100644 --- a/Sources/HTTPServer/NIOHTTPServer.swift +++ b/Sources/HTTPServer/NIOHTTPServer.swift @@ -25,7 +25,7 @@ import NIOPosix import NIOSSL import SwiftASN1 import Synchronization -import X509 +public import X509 /// A generic HTTP server that can handle incoming HTTP requests. /// @@ -85,6 +85,35 @@ public struct NIOHTTPServer: HTTPServerProtocol { var listeningAddressState: NIOLockedValueBox + /// Task-local storage for connection-specific information accessible from request handlers. + /// + /// Use this to access data such as the peer's validated certificate chain. + @TaskLocal public static var context: Context = Context() + + /// Connection-specific information available during request handling. + /// + /// Provides access to data such as the peer's validated certificate chain. + public struct Context: Sendable { + var peerCertificateChainFuture: EventLoopFuture? + + init(_ peerCertificateChainFuture: EventLoopFuture? = nil) { + self.peerCertificateChainFuture = peerCertificateChainFuture + } + + /// The peer's validated certificate chain. This returns `nil` if a + /// ``HTTPServerConfiguration/TransportSecurity/CustomCertificateVerificationCallback`` was not set in + /// the ``HTTPServerConfiguration/TransportSecurity`` property of the server configuration, or if the peer did + /// not authenticate with certificates. + public var peerCertificateChain: X509.ValidatedCertificateChain? { + get async throws { + if let certs = try await self.peerCertificateChainFuture?.get() { + return .init(uncheckedCertificateChain: try certs.map { try Certificate($0) }) + } + return nil + } + } + } + /// Create a new ``HTTPServer`` implemented over `SwiftNIO`. /// - Parameters: /// - logger: A logger instance for recording server events and debugging information. @@ -171,20 +200,8 @@ public struct NIOHTTPServer: HTTPServerProtocol { httpServerHTTP2Configuration: self.configuration.http2 ) - let certificateChain = try certificateChain - .map { - try NIOSSLCertificate( - bytes: $0.serializeAsPEM().derBytes, - format: .der - ) - } - .map { NIOSSLCertificateSource.certificate($0) } - let privateKey = NIOSSLPrivateKeySource.privateKey( - try NIOSSLPrivateKey( - bytes: privateKey.serializeAsPEM().derBytes, - format: .der - ) - ) + let certificateChain = try certificateChain.map { try NIOSSLCertificateSource($0) } + let privateKey = try NIOSSLPrivateKeySource(privateKey) var tlsConfiguration: TLSConfiguration = .makeServerConfiguration( certificateChain: certificateChain, @@ -218,39 +235,14 @@ public struct NIOHTTPServer: HTTPServerProtocol { http2Configuration: http2Config ) - case .mTLS(let certificateChain, let privateKey, let trustRoots): + case .mTLS(let certificateChain, let privateKey, let trustRoots, let verificationCallback): let http2Config = NIOHTTP2Handler.Configuration( httpServerHTTP2Configuration: configuration.http2 ) - let certificateChain = try certificateChain - .map { - try NIOSSLCertificate( - bytes: $0.serializeAsPEM().derBytes, - format: .der - ) - } - .map { NIOSSLCertificateSource.certificate($0) } - let privateKey = NIOSSLPrivateKeySource.privateKey( - try NIOSSLPrivateKey( - bytes: privateKey.serializeAsPEM().derBytes, - format: .der - ) - ) - - let nioTrustRoots: NIOSSLTrustRoots - if let trustRoots { - nioTrustRoots = .certificates( - try trustRoots.map { - try NIOSSLCertificate( - bytes: $0.serializeAsPEM().derBytes, - format: .der - ) - } - ) - } else { - nioTrustRoots = .default - } + let certificateChain = try certificateChain.map { try NIOSSLCertificateSource($0) } + let privateKey = try NIOSSLPrivateKeySource(privateKey) + let nioTrustRoots = try NIOSSLTrustRoots(treatingNilAsSystemTrustRoots: trustRoots) var tlsConfiguration: TLSConfiguration = .makeServerConfigurationWithMTLS( certificateChain: certificateChain, @@ -264,27 +256,16 @@ public struct NIOHTTPServer: HTTPServerProtocol { tlsConfiguration: tlsConfiguration, handler: handler, asyncChannelConfiguration: asyncChannelConfiguration, - http2Configuration: http2Config + http2Configuration: http2Config, + verificationCallback: verificationCallback ) - case .reloadingMTLS(let certificateReloader, let trustRoots): + case .reloadingMTLS(let certificateReloader, let trustRoots, let verificationCallback): let http2Config = NIOHTTP2Handler.Configuration( httpServerHTTP2Configuration: configuration.http2 ) - let nioTrustRoots: NIOSSLTrustRoots - if let trustRoots { - nioTrustRoots = .certificates( - try trustRoots.map { - try NIOSSLCertificate( - bytes: $0.serializeAsPEM().derBytes, - format: .der - ) - } - ) - } else { - nioTrustRoots = .default - } + let nioTrustRoots = try NIOSSLTrustRoots(treatingNilAsSystemTrustRoots: trustRoots) var tlsConfiguration: TLSConfiguration = try .makeServerConfigurationWithMTLS( certificateReloader: certificateReloader, @@ -297,7 +278,8 @@ public struct NIOHTTPServer: HTTPServerProtocol { tlsConfiguration: tlsConfiguration, handler: handler, asyncChannelConfiguration: asyncChannelConfiguration, - http2Configuration: http2Config + http2Configuration: http2Config, + verificationCallback: verificationCallback ) } } @@ -343,7 +325,8 @@ public struct NIOHTTPServer: HTTPServerProtocol { tlsConfiguration: TLSConfiguration, handler: some HTTPServerRequestHandler, asyncChannelConfiguration: NIOAsyncChannel.Configuration, - http2Configuration: NIOHTTP2Handler.Configuration + http2Configuration: NIOHTTP2Handler.Configuration, + verificationCallback: HTTPServerConfiguration.TransportSecurity.CustomCertificateVerificationCallback? = nil ) async throws { switch bindTarget.backing { case .hostAndPort(let host, let port): @@ -352,11 +335,7 @@ public struct NIOHTTPServer: HTTPServerProtocol { .bind(host: host, port: port) { channel in channel.eventLoop.makeCompletedFuture { try channel.pipeline.syncOperations - .addHandler( - NIOSSLServerHandler( - context: .init(configuration: tlsConfiguration) - ) - ) + .addHandler(self.makeSSLServerHandler(tlsConfiguration, verificationCallback)) }.flatMap { channel.configureAsyncHTTPServerPipeline(http2Configuration: http2Configuration) { channel in channel.eventLoop.makeCompletedFuture { @@ -368,7 +347,7 @@ public struct NIOHTTPServer: HTTPServerProtocol { ) } } http2ConnectionInitializer: { channel in - channel.eventLoop.makeSucceededVoidFuture() + channel.eventLoop.makeCompletedFuture(.success(channel)) } http2StreamInitializer: { channel in channel.eventLoop.makeCompletedFuture { try channel.pipeline.syncOperations @@ -395,20 +374,26 @@ public struct NIOHTTPServer: HTTPServerProtocol { try await withThrowingDiscardingTaskGroup { connectionGroup in switch try await upgradeResult.get() { case .http1_1(let http1Channel): - connectionGroup.addTask { - try await self.handleRequestChannel( - channel: http1Channel, - handler: handler - ) + let chainFuture = http1Channel.channel.nioSSL_peerValidatedCertificateChain() + Self.$context.withValue(Context(chainFuture)) { + connectionGroup.addTask { + try await self.handleRequestChannel( + channel: http1Channel, + handler: handler + ) + } } - case .http2((_, let http2Multiplexer)): + case .http2((let http2Connection, let http2Multiplexer)): do { + let chainFuture = http2Connection.nioSSL_peerValidatedCertificateChain() for try await http2StreamChannel in http2Multiplexer.inbound { - connectionGroup.addTask { - try await self.handleRequestChannel( - channel: http2StreamChannel, - handler: handler - ) + Self.$context.withValue(Context(chainFuture)) { + connectionGroup.addTask { + try await self.handleRequestChannel( + channel: http2StreamChannel, + handler: handler + ) + } } } } catch { @@ -507,6 +492,25 @@ public struct NIOHTTPServer: HTTPServerProtocol { } } +@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) +extension NIOHTTPServer { + fileprivate func makeSSLServerHandler( + _ tlsConfiguration: TLSConfiguration, + _ customVerificationCallback: HTTPServerConfiguration.TransportSecurity.CustomCertificateVerificationCallback? + ) throws -> NIOSSLServerHandler { + if let customVerificationCallback { + return try NIOSSLServerHandler( + context: .init(configuration: tlsConfiguration), + customVerificationCallbackWithMetadata: { certificates, promise in + customVerificationCallback(certificates, promise) + } + ) + } else { + return try NIOSSLServerHandler(context: .init(configuration: tlsConfiguration)) + } + } +} + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) extension NIOHTTP2Handler.Configuration { init(httpServerHTTP2Configuration http2Config: HTTPServerConfiguration.HTTP2) { diff --git a/Sources/HTTPServer/NIOSSL+X509.swift b/Sources/HTTPServer/NIOSSL+X509.swift new file mode 100644 index 0000000..f80b5d4 --- /dev/null +++ b/Sources/HTTPServer/NIOSSL+X509.swift @@ -0,0 +1,57 @@ +import NIOSSL +import SwiftASN1 +import X509 + +/// Some convenience helpers for converting between NIOSSL and X509 certificate and private key types. + +// MARK: X509 to NIOSSL + +@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) +extension NIOSSLCertificate { + convenience init(_ certificate: Certificate) throws { + var serializer = DER.Serializer() + try certificate.serialize(into: &serializer) + try self.init(bytes: serializer.serializedBytes, format: .der) + } +} + +@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) +extension NIOSSLPrivateKey { + convenience init(_ privateKey: Certificate.PrivateKey) throws { + try self.init(bytes: try privateKey.serializeAsPEM().derBytes, format: .der) + } +} + +@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) +extension NIOSSLCertificateSource { + init(_ certificate: Certificate) throws { + self = .certificate(try NIOSSLCertificate(certificate)) + } +} + +@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) +extension NIOSSLPrivateKeySource { + init(_ privateKey: Certificate.PrivateKey) throws { + self = .privateKey(try NIOSSLPrivateKey(privateKey)) + } +} + +@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) +extension NIOSSLTrustRoots { + init(treatingNilAsSystemTrustRoots certificates: [Certificate]?) throws { + if let certificates { + self = .certificates(try certificates.map { try NIOSSLCertificate($0) }) + } else { + self = .default + } + } +} + +// MARK: NIOSSL to X509 + +@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) +extension Certificate { + init(_ certificate: NIOSSLCertificate) throws { + try self.init(derEncoded: certificate.toDERBytes()) + } +} diff --git a/Tests/HTTPServerTests/HTTPServerTests.swift b/Tests/HTTPServerTests/HTTPServerTests.swift index dc210d1..17393af 100644 --- a/Tests/HTTPServerTests/HTTPServerTests.swift +++ b/Tests/HTTPServerTests/HTTPServerTests.swift @@ -25,32 +25,41 @@ struct HTTPServerTests { logger: Logger(label: "Test"), configuration: .init(bindTarget: .hostAndPort(host: "127.0.0.1", port: 0)) ) - try await server.serve { request, context, requestBodyAndTrailers, responseSender in - _ = try await requestBodyAndTrailers.collect(upTo: 100) { _ in } - // Uncommenting this would cause a "requestBodyAndTrailers consumed more than once" error. -// _ = try await requestBodyAndTrailers.collect(upTo: 100) { _ in } - - let responseConcludingWriter = try await responseSender.send(HTTPResponse(status: .ok)) - // Uncommenting this would cause a "responseSender consumed more than once" error. -// let responseConcludingWriter2 = try await responseSender.send(HTTPResponse(status: .ok)) - - // Uncommenting this would cause a "requestBodyAndTrailers consumed more than once" error. -// _ = try await requestBodyAndTrailers.consumeAndConclude { reader in -// var reader = reader -// try await reader.read { elem in } -// } - - try await responseConcludingWriter.produceAndConclude { writer in - var writer = writer - try await writer.write([1,2].span) - return nil + + try await withThrowingTaskGroup { group in + group.addTask { + try await server.serve { request, context, requestBodyAndTrailers, responseSender in + _ = try await requestBodyAndTrailers.collect(upTo: 100) { _ in } + // Uncommenting this would cause a "requestBodyAndTrailers consumed more than once" error. + // _ = try await requestBodyAndTrailers.collect(upTo: 100) { _ in } + + let responseConcludingWriter = try await responseSender.send(HTTPResponse(status: .ok)) + // Uncommenting this would cause a "responseSender consumed more than once" error. + // let responseConcludingWriter2 = try await responseSender.send(HTTPResponse(status: .ok)) + + // Uncommenting this would cause a "requestBodyAndTrailers consumed more than once" error. + // _ = try await requestBodyAndTrailers.consumeAndConclude { reader in + // var reader = reader + // try await reader.read { elem in } + // } + + try await responseConcludingWriter.produceAndConclude { writer in + var writer = writer + try await writer.write([1, 2].span) + return nil + } + + // Uncommenting this would cause a "responseConcludingWriter consumed more than once" error. + // try await responseConcludingWriter.writeAndConclude( + // element: [1, 2].span, + // finalElement: HTTPFields(dictionaryLiteral: (.acceptEncoding, "Encoding")) + // ) + } } - // Uncommenting this would cause a "responseConcludingWriter consumed more than once" error. -// try await responseConcludingWriter.writeAndConclude( -// element: [1, 2].span, -// finalElement: HTTPFields(dictionaryLiteral: (.acceptEncoding, "Encoding")) -// ) + _ = try await server.listeningAddress + + group.cancelAll() } } } diff --git a/Tests/HTTPServerTests/NIOHTTPServerTests.swift b/Tests/HTTPServerTests/NIOHTTPServerTests.swift index 155ec1a..937ae54 100644 --- a/Tests/HTTPServerTests/NIOHTTPServerTests.swift +++ b/Tests/HTTPServerTests/NIOHTTPServerTests.swift @@ -14,10 +14,9 @@ import HTTPTypes import Logging import NIOCore -import NIOHTTP1 import NIOHTTPTypes -import NIOPosix import Testing +import X509 @testable import HTTPServer @@ -27,6 +26,28 @@ import Dispatch @Suite struct NIOHTTPServerTests { + static let reqHead = HTTPRequestPart.head(.init(method: .post, scheme: "http", authority: "", path: "/")) + static let bodyData = ByteBuffer(repeating: 5, count: 100) + static let reqBody = HTTPRequestPart.body(Self.bodyData) + static let trailer: HTTPFields = [.trailer: "test_trailer"] + static let reqEnd = HTTPRequestPart.end(trailer) + + static func clientResponseHandler( + _ response: HTTPResponsePart, + expectedStatus: HTTPResponse.Status, + expectedBody: ByteBuffer, + expectedTrailers: HTTPFields? = nil + ) async throws { + switch response { + case .head(let head): + try #require(head.status == expectedStatus) + case .body(let body): + try #require(body == expectedBody) + case .end(let trailers): + try #require(trailers == expectedTrailers) + } + } + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) @Test("Obtain the listening address correctly") func testListeningAddress() async throws { @@ -54,4 +75,142 @@ struct NIOHTTPServerTests { try await server.listeningAddress } } + + @Test("Plaintext request-response") + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + func testPlaintext() async throws { + let server = NIOHTTPServer( + logger: Logger(label: "Test"), + configuration: .init(bindTarget: .hostAndPort(host: "127.0.0.1", port: 0)) + ) + + try await withThrowingTaskGroup { group in + group.addTask { + try await server.serve { request, requestContext, reader, responseWriter in + #expect(request.method == .post) + #expect(request.path == "/") + + var buffer = ByteBuffer() + let finalElement = try await reader.consumeAndConclude { bodyReader in + var bodyReader = bodyReader + return try await bodyReader.collect(upTo: Self.bodyData.readableBytes + 1) { body in + buffer.writeBytes(body.bytes) + } + } + #expect(buffer == Self.bodyData) + #expect(finalElement == Self.trailer) + + let responseBodySender = try await responseWriter.send(.init(status: .ok)) + try await responseBodySender.produceAndConclude { responseBodyWriter in + var responseBodyWriter = responseBodyWriter + try await responseBodyWriter.write([1, 2].span) + return [.trailer: "test_trailer"] + } + } + } + + let serverAddress = try await server.listeningAddress + + let client = try await setUpClient(host: serverAddress.host, port: serverAddress.port) + try await client.executeThenClose { inbound, outbound in + try await outbound.write(Self.reqHead) + try await outbound.write(Self.reqBody) + try await outbound.write(Self.reqEnd) + + for try await response in inbound { + try await Self.clientResponseHandler( + response, + expectedStatus: .ok, + expectedBody: .init([1, 2]), + expectedTrailers: Self.trailer + ) + } + } + + group.cancelAll() + } + } + + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + @Test("mTLS request-response with custom verification callback returning peer certificates", .serialized, arguments: ["http/1.1", "h2"]) + func testMTLS(applicationProtocol: String) async throws { + let serverChain = try TestCA.makeSelfSignedChain() + let clientChain = try TestCA.makeSelfSignedChain() + + let server = NIOHTTPServer( + logger: Logger(label: "Test"), + configuration: .init( + bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), + transportSecurity: .mTLS( + certificateChain: [serverChain.leaf], + privateKey: serverChain.privateKey, + trustRoots: [clientChain.ca], + customCertificateVerificationCallback: { certificates, promise in + // Return the peer's certificate chain; this must then be accessible in the request handler + promise.succeed(.certificateVerified(.init(.init(certificates)))) + } + ) + ) + ) + + try await withThrowingTaskGroup { group in + group.addTask { + try await server.serve { request, requestContext, reader, responseWriter in + #expect(request.method == .post) + #expect(request.path == "/") + + do { + let peerChain = try #require(try await NIOHTTPServer.context.peerCertificateChain) + #expect(Array(peerChain) == [clientChain.leaf]) + } catch { + Issue.record("Could not obtain the peer's certificate chain: \(error)") + } + + let (buffer, finalElement) = try await reader.consumeAndConclude { bodyReader in + var bodyReader = bodyReader + var buffer = ByteBuffer() + _ = try await bodyReader.collect(upTo: Self.bodyData.readableBytes + 1) { body in + buffer.writeBytes(body.bytes) + } + return buffer + } + #expect(buffer == Self.bodyData) + #expect(finalElement == Self.trailer) + + let sender = try await responseWriter.send(.init(status: .ok)) + try await sender.produceAndConclude { bodyWriter in + var bodyWriter = bodyWriter + try await bodyWriter.write([1, 2].span) + return [.trailer: "test_trailer"] + } + } + } + + let serverAddress = try await server.listeningAddress + + let clientChannel = try await setUpClientWithMTLS( + at: serverAddress, + chain: clientChain, + trustRoots: [serverChain.ca], + applicationProtocol: applicationProtocol + ) + + try await clientChannel.executeThenClose { inbound, outbound in + try await outbound.write(Self.reqHead) + try await outbound.write(Self.reqBody) + try await outbound.write(Self.reqEnd) + + for try await response in inbound { + try await Self.clientResponseHandler( + response, + expectedStatus: .ok, + expectedBody: .init([1, 2]), + expectedTrailers: Self.trailer + ) + } + } + // Cancel the server and client task once we know the client has received the response + group.cancelAll() + } + } } diff --git a/Tests/HTTPServerTests/Utilities/Certificates.swift b/Tests/HTTPServerTests/Utilities/Certificates.swift new file mode 100644 index 0000000..a16a195 --- /dev/null +++ b/Tests/HTTPServerTests/Utilities/Certificates.swift @@ -0,0 +1,64 @@ +import Crypto +import Foundation +import X509 + +struct ChainPrivateKeyPair { + let leaf: Certificate + let ca: Certificate + let privateKey: Certificate.PrivateKey +} + +struct TestCA { + static func makeSelfSignedChain() throws -> ChainPrivateKeyPair { + let caKey = P384.Signing.PrivateKey() + let caName = try DistinguishedName { OrganizationName("Test CA") } + let ca = try makeCA(name: caName, privateKey: caKey) + + let leafKey = P384.Signing.PrivateKey() + let leafName = try DistinguishedName { OrganizationName("Test") } + + let leaf = try makeCertificate( + issuerName: caName, + issuerKey: .init(caKey), + publicKey: .init(leafKey.publicKey), + subject: leafName, + extensions: .init() + ) + + return ChainPrivateKeyPair(leaf: leaf, ca: ca, privateKey: .init(leafKey)) + } + + static func makeCA(name: DistinguishedName, privateKey: P384.Signing.PrivateKey) throws -> Certificate { + try makeCertificate( + issuerName: name, + issuerKey: .init(privateKey), + publicKey: .init(privateKey.publicKey), + subject: name, + extensions: try .init { + BasicConstraints.isCertificateAuthority(maxPathLength: nil) + } + ) + } + + static func makeCertificate( + issuerName: DistinguishedName, + issuerKey: Certificate.PrivateKey, + publicKey: Certificate.PublicKey, + subject: DistinguishedName, + extensions: Certificate.Extensions + ) throws -> Certificate { + try Certificate( + version: .v3, + serialNumber: .init(), + publicKey: publicKey, + notValidBefore: .now - 60, + notValidAfter: .now + 60, + issuer: issuerName, + subject: subject, + signatureAlgorithm: .ecdsaWithSHA384, + extensions: extensions, + issuerPrivateKey: issuerKey + ) + } +} + diff --git a/Tests/HTTPServerTests/Utilities/Client.swift b/Tests/HTTPServerTests/Utilities/Client.swift new file mode 100644 index 0000000..a897569 --- /dev/null +++ b/Tests/HTTPServerTests/Utilities/Client.swift @@ -0,0 +1,79 @@ +@testable import HTTPServer +import NIOCore +import NIOHTTPTypes +import NIOHTTPTypesHTTP1 +import NIOHTTPTypesHTTP2 +import NIOPosix +import NIOSSL +import X509 + +typealias ClientChannel = NIOAsyncChannel + +@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) +func setUpClient(host: String, port: Int) async throws -> ClientChannel { + try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) + .channelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) + .connect(to: try .init(ipAddress: host, port: port)) { channel in + channel.eventLoop.makeCompletedFuture { + try channel.pipeline.syncOperations.addHTTPClientHandlers() + try channel.pipeline.syncOperations.addHandler(HTTP1ToHTTPClientCodec()) + + return try ClientChannel(wrappingChannelSynchronously: channel, configuration: .init()) + } + } +} + +@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) +func setUpClientWithMTLS( + at address: HTTPServer.SocketAddress, + chain: ChainPrivateKeyPair, + trustRoots: [Certificate], + applicationProtocol: String, +) async throws -> ClientChannel { + var clientTLSConfig = TLSConfiguration.makeClientConfiguration() + clientTLSConfig.certificateChain = [try NIOSSLCertificateSource(chain.leaf)] + clientTLSConfig.privateKey = .privateKey(try .init(chain.privateKey)) + clientTLSConfig.trustRoots = .certificates(try trustRoots.map { try NIOSSLCertificate($0) }) + clientTLSConfig.certificateVerification = .noHostnameVerification + clientTLSConfig.applicationProtocols = [applicationProtocol] + + let sslContext = try NIOSSLContext(configuration: clientTLSConfig) + + let clientNegotiatedChannel = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) + .channelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) + .connect(to: try .init(ipAddress: address.host, port: address.port)) { channel in + channel.eventLoop.makeCompletedFuture { + let sslHandler = try NIOSSLClientHandler(context: sslContext, serverHostname: nil) + try channel.pipeline.syncOperations.addHandler(sslHandler) + }.flatMap { + channel.configureHTTP2AsyncSecureUpgrade( + http1ConnectionInitializer: { channel in + channel.eventLoop.makeCompletedFuture { + try channel.pipeline.syncOperations.addHTTPClientHandlers() + try channel.pipeline.syncOperations.addHandlers(HTTP1ToHTTPClientCodec()) + + return try ClientChannel(wrappingChannelSynchronously: channel, configuration: .init()) + } + }, + http2ConnectionInitializer: { channel in + channel.configureAsyncHTTP2Pipeline(mode: .client) { $0.eventLoop.makeSucceededFuture($0) } + } + ) + } + }.get() + + switch clientNegotiatedChannel { + case .http1_1(let http1Channel): + precondition(applicationProtocol == "http/1.1", "Unexpectedly established a HTTP 1.1 channel") + return http1Channel + + case .http2(let http2Channel): + precondition(applicationProtocol == "h2", "Unexpectedly established a HTTP 2 channel") + return try await http2Channel.openStream { channel in + channel.eventLoop.makeCompletedFuture() { + try channel.pipeline.syncOperations.addHandler(HTTP2FramePayloadToHTTPClientCodec()) + return try ClientChannel(wrappingChannelSynchronously: channel, configuration: .init()) + } + } + } +} diff --git a/Tests/HTTPServerTests/Utilities/SocketAddress.swift b/Tests/HTTPServerTests/Utilities/SocketAddress.swift new file mode 100644 index 0000000..d75a1f2 --- /dev/null +++ b/Tests/HTTPServerTests/Utilities/SocketAddress.swift @@ -0,0 +1,21 @@ +@testable import HTTPServer + +extension HTTPServer.SocketAddress { + var host: String { + switch self.base { + case .ipv4(let ipv4): + return ipv4.host + case .ipv6(let ipv6): + return ipv6.host + } + } + + var port: Int { + switch self.base { + case .ipv4(let ipv4): + return ipv4.port + case .ipv6(let ipv6): + return ipv6.port + } + } +} From bcab25782063b61de4c37a022e236d9e9e53cfeb Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Mon, 8 Dec 2025 10:42:43 +0000 Subject: [PATCH 02/11] Rename `HTTPServerConfiguration` to `NIOHTTPServerConfiguration` --- Sources/HTTPServer/NIOHTTPServer.swift | 20 +++++++++---------- ...swift => NIOHTTPServerConfiguration.swift} | 6 +++--- 2 files changed, 13 insertions(+), 13 deletions(-) rename Sources/HTTPServer/{HTTPServerConfiguration.swift => NIOHTTPServerConfiguration.swift} (98%) diff --git a/Sources/HTTPServer/NIOHTTPServer.swift b/Sources/HTTPServer/NIOHTTPServer.swift index 955c275..f11c11e 100644 --- a/Sources/HTTPServer/NIOHTTPServer.swift +++ b/Sources/HTTPServer/NIOHTTPServer.swift @@ -40,7 +40,7 @@ public import X509 /// ## Usage /// /// ```swift -/// let configuration = HTTPServerConfiguration( +/// let configuration = NIOHTTPServerConfiguration( /// bindTarget: .hostAndPort(host: "localhost", port: 8080), /// tlsConfiguration: .insecure() /// ) @@ -81,7 +81,7 @@ public struct NIOHTTPServer: HTTPServerProtocol { public typealias ResponseWriter = HTTPResponseConcludingAsyncWriter private let logger: Logger - private let configuration: HTTPServerConfiguration + private let configuration: NIOHTTPServerConfiguration var listeningAddressState: NIOLockedValueBox @@ -101,8 +101,8 @@ public struct NIOHTTPServer: HTTPServerProtocol { } /// The peer's validated certificate chain. This returns `nil` if a - /// ``HTTPServerConfiguration/TransportSecurity/CustomCertificateVerificationCallback`` was not set in - /// the ``HTTPServerConfiguration/TransportSecurity`` property of the server configuration, or if the peer did + /// ``NIOHTTPServerConfiguration/TransportSecurity/CustomCertificateVerificationCallback`` was not set in the + /// ``NIOHTTPServerConfiguration/TransportSecurity`` property of the server configuration, or if the peer did /// not authenticate with certificates. public var peerCertificateChain: X509.ValidatedCertificateChain? { get async throws { @@ -120,7 +120,7 @@ public struct NIOHTTPServer: HTTPServerProtocol { /// - configuration: The server configuration including bind target and TLS settings. public init( logger: Logger, - configuration: HTTPServerConfiguration, + configuration: NIOHTTPServerConfiguration, ) { self.logger = logger self.configuration = configuration @@ -285,7 +285,7 @@ public struct NIOHTTPServer: HTTPServerProtocol { } private func serveInsecureHTTP1_1( - bindTarget: HTTPServerConfiguration.BindTarget, + bindTarget: NIOHTTPServerConfiguration.BindTarget, handler: some HTTPServerRequestHandler, asyncChannelConfiguration: NIOAsyncChannel.Configuration ) async throws { @@ -321,12 +321,12 @@ public struct NIOHTTPServer: HTTPServerProtocol { } private func serveSecureUpgrade( - bindTarget: HTTPServerConfiguration.BindTarget, + bindTarget: NIOHTTPServerConfiguration.BindTarget, tlsConfiguration: TLSConfiguration, handler: some HTTPServerRequestHandler, asyncChannelConfiguration: NIOAsyncChannel.Configuration, http2Configuration: NIOHTTP2Handler.Configuration, - verificationCallback: HTTPServerConfiguration.TransportSecurity.CustomCertificateVerificationCallback? = nil + verificationCallback: NIOHTTPServerConfiguration.TransportSecurity.CustomCertificateVerificationCallback? = nil ) async throws { switch bindTarget.backing { case .hostAndPort(let host, let port): @@ -496,7 +496,7 @@ public struct NIOHTTPServer: HTTPServerProtocol { extension NIOHTTPServer { fileprivate func makeSSLServerHandler( _ tlsConfiguration: TLSConfiguration, - _ customVerificationCallback: HTTPServerConfiguration.TransportSecurity.CustomCertificateVerificationCallback? + _ customVerificationCallback: NIOHTTPServerConfiguration.TransportSecurity.CustomCertificateVerificationCallback? ) throws -> NIOSSLServerHandler { if let customVerificationCallback { return try NIOSSLServerHandler( @@ -513,7 +513,7 @@ extension NIOHTTPServer { @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) extension NIOHTTP2Handler.Configuration { - init(httpServerHTTP2Configuration http2Config: HTTPServerConfiguration.HTTP2) { + init(httpServerHTTP2Configuration http2Config: NIOHTTPServerConfiguration.HTTP2) { let clampedTargetWindowSize = Self.clampTargetWindowSize(http2Config.targetWindowSize) let clampedMaxFrameSize = Self.clampMaxFrameSize(http2Config.maxFrameSize) diff --git a/Sources/HTTPServer/HTTPServerConfiguration.swift b/Sources/HTTPServer/NIOHTTPServerConfiguration.swift similarity index 98% rename from Sources/HTTPServer/HTTPServerConfiguration.swift rename to Sources/HTTPServer/NIOHTTPServerConfiguration.swift index aae2b10..99e6fc7 100644 --- a/Sources/HTTPServer/HTTPServerConfiguration.swift +++ b/Sources/HTTPServer/NIOHTTPServerConfiguration.swift @@ -16,12 +16,12 @@ public import NIOCore public import NIOSSL public import X509 -/// Configuration settings for the HTTP server. +/// Configuration settings for ``NIOHTTPServer``. /// /// This structure contains all the necessary configuration options for setting up -/// and running an HTTP server, including network binding and TLS settings. +/// and running ``NIOHTTPServer``, including network binding and TLS settings. @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) -public struct HTTPServerConfiguration: Sendable { +public struct NIOHTTPServerConfiguration: Sendable { /// Specifies where the server should bind and listen for incoming connections. /// /// Currently supports binding to a specific host and port combination. From 0bb4f4cf9a8a37631fabd25126cda9fc6baa71b2 Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Mon, 8 Dec 2025 10:49:50 +0000 Subject: [PATCH 03/11] Rename `Context` to `ConnectionContext` --- Sources/HTTPServer/NIOHTTPServer.swift | 8 ++++---- Tests/HTTPServerTests/NIOHTTPServerTests.swift | 2 +- Tests/HTTPServerTests/Utilities/Certificates.swift | 3 +-- Tests/HTTPServerTests/Utilities/Client.swift | 5 +++-- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Sources/HTTPServer/NIOHTTPServer.swift b/Sources/HTTPServer/NIOHTTPServer.swift index f11c11e..8475a47 100644 --- a/Sources/HTTPServer/NIOHTTPServer.swift +++ b/Sources/HTTPServer/NIOHTTPServer.swift @@ -88,12 +88,12 @@ public struct NIOHTTPServer: HTTPServerProtocol { /// Task-local storage for connection-specific information accessible from request handlers. /// /// Use this to access data such as the peer's validated certificate chain. - @TaskLocal public static var context: Context = Context() + @TaskLocal public static var connectionContext = ConnectionContext() /// Connection-specific information available during request handling. /// /// Provides access to data such as the peer's validated certificate chain. - public struct Context: Sendable { + public struct ConnectionContext: Sendable { var peerCertificateChainFuture: EventLoopFuture? init(_ peerCertificateChainFuture: EventLoopFuture? = nil) { @@ -375,7 +375,7 @@ public struct NIOHTTPServer: HTTPServerProtocol { switch try await upgradeResult.get() { case .http1_1(let http1Channel): let chainFuture = http1Channel.channel.nioSSL_peerValidatedCertificateChain() - Self.$context.withValue(Context(chainFuture)) { + Self.$connectionContext.withValue(ConnectionContext(chainFuture)) { connectionGroup.addTask { try await self.handleRequestChannel( channel: http1Channel, @@ -387,7 +387,7 @@ public struct NIOHTTPServer: HTTPServerProtocol { do { let chainFuture = http2Connection.nioSSL_peerValidatedCertificateChain() for try await http2StreamChannel in http2Multiplexer.inbound { - Self.$context.withValue(Context(chainFuture)) { + Self.$connectionContext.withValue(ConnectionContext(chainFuture)) { connectionGroup.addTask { try await self.handleRequestChannel( channel: http2StreamChannel, diff --git a/Tests/HTTPServerTests/NIOHTTPServerTests.swift b/Tests/HTTPServerTests/NIOHTTPServerTests.swift index 937ae54..ad3538d 100644 --- a/Tests/HTTPServerTests/NIOHTTPServerTests.swift +++ b/Tests/HTTPServerTests/NIOHTTPServerTests.swift @@ -160,7 +160,7 @@ struct NIOHTTPServerTests { #expect(request.path == "/") do { - let peerChain = try #require(try await NIOHTTPServer.context.peerCertificateChain) + let peerChain = try #require(try await NIOHTTPServer.connectionContext.peerCertificateChain) #expect(Array(peerChain) == [clientChain.leaf]) } catch { Issue.record("Could not obtain the peer's certificate chain: \(error)") diff --git a/Tests/HTTPServerTests/Utilities/Certificates.swift b/Tests/HTTPServerTests/Utilities/Certificates.swift index a16a195..0aadeb0 100644 --- a/Tests/HTTPServerTests/Utilities/Certificates.swift +++ b/Tests/HTTPServerTests/Utilities/Certificates.swift @@ -39,7 +39,7 @@ struct TestCA { } ) } - + static func makeCertificate( issuerName: DistinguishedName, issuerKey: Certificate.PrivateKey, @@ -61,4 +61,3 @@ struct TestCA { ) } } - diff --git a/Tests/HTTPServerTests/Utilities/Client.swift b/Tests/HTTPServerTests/Utilities/Client.swift index a897569..6bc5609 100644 --- a/Tests/HTTPServerTests/Utilities/Client.swift +++ b/Tests/HTTPServerTests/Utilities/Client.swift @@ -1,4 +1,3 @@ -@testable import HTTPServer import NIOCore import NIOHTTPTypes import NIOHTTPTypesHTTP1 @@ -7,6 +6,8 @@ import NIOPosix import NIOSSL import X509 +@testable import HTTPServer + typealias ClientChannel = NIOAsyncChannel @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) @@ -70,7 +71,7 @@ func setUpClientWithMTLS( case .http2(let http2Channel): precondition(applicationProtocol == "h2", "Unexpectedly established a HTTP 2 channel") return try await http2Channel.openStream { channel in - channel.eventLoop.makeCompletedFuture() { + channel.eventLoop.makeCompletedFuture { try channel.pipeline.syncOperations.addHandler(HTTP2FramePayloadToHTTPClientCodec()) return try ClientChannel(wrappingChannelSynchronously: channel, configuration: .init()) } From f7681db47d74c33d2781aec7f19c32ef7c65abe3 Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Mon, 8 Dec 2025 12:34:31 +0000 Subject: [PATCH 04/11] Raise NIOSSL dependency --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index 8113da2..a6646d8 100644 --- a/Package.swift +++ b/Package.swift @@ -27,7 +27,7 @@ let package = Package( .package(url: "https://github.com/apple/swift-certificates.git", from: "1.16.0"), .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"), .package(url: "https://github.com/apple/swift-nio.git", from: "2.0.0"), - .package(url: "https://github.com/apple/swift-nio-ssl.git", from: "2.0.0"), + .package(url: "https://github.com/apple/swift-nio-ssl.git", from: "2.36.0"), .package(url: "https://github.com/apple/swift-nio-extras.git", from: "1.30.0"), .package(url: "https://github.com/apple/swift-nio-http2.git", from: "1.0.0"), ], From 32f8a843ded096612e9d3acaa77d67cea0e6ff43 Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Mon, 8 Dec 2025 13:40:54 +0000 Subject: [PATCH 05/11] Add license headers --- Sources/HTTPServer/NIOSSL+X509.swift | 13 +++++++++++++ Tests/HTTPServerTests/Utilities/Certificates.swift | 13 +++++++++++++ Tests/HTTPServerTests/Utilities/Client.swift | 13 +++++++++++++ Tests/HTTPServerTests/Utilities/SocketAddress.swift | 13 +++++++++++++ 4 files changed, 52 insertions(+) diff --git a/Sources/HTTPServer/NIOSSL+X509.swift b/Sources/HTTPServer/NIOSSL+X509.swift index f80b5d4..1048691 100644 --- a/Sources/HTTPServer/NIOSSL+X509.swift +++ b/Sources/HTTPServer/NIOSSL+X509.swift @@ -1,3 +1,16 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift HTTP Server open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift HTTP Server project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + import NIOSSL import SwiftASN1 import X509 diff --git a/Tests/HTTPServerTests/Utilities/Certificates.swift b/Tests/HTTPServerTests/Utilities/Certificates.swift index 0aadeb0..e6afb1a 100644 --- a/Tests/HTTPServerTests/Utilities/Certificates.swift +++ b/Tests/HTTPServerTests/Utilities/Certificates.swift @@ -1,3 +1,16 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift HTTP Server open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift HTTP Server project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + import Crypto import Foundation import X509 diff --git a/Tests/HTTPServerTests/Utilities/Client.swift b/Tests/HTTPServerTests/Utilities/Client.swift index 6bc5609..60a2522 100644 --- a/Tests/HTTPServerTests/Utilities/Client.swift +++ b/Tests/HTTPServerTests/Utilities/Client.swift @@ -1,3 +1,16 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift HTTP Server open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift HTTP Server project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + import NIOCore import NIOHTTPTypes import NIOHTTPTypesHTTP1 diff --git a/Tests/HTTPServerTests/Utilities/SocketAddress.swift b/Tests/HTTPServerTests/Utilities/SocketAddress.swift index d75a1f2..a6f5e98 100644 --- a/Tests/HTTPServerTests/Utilities/SocketAddress.swift +++ b/Tests/HTTPServerTests/Utilities/SocketAddress.swift @@ -1,3 +1,16 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift HTTP Server open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift HTTP Server project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + @testable import HTTPServer extension HTTPServer.SocketAddress { From a6be2ce0c360d8ce592ed37e460afb65dd48d28c Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Wed, 10 Dec 2025 14:08:59 +0000 Subject: [PATCH 06/11] Drop public import of `NIOSSL` and `NIOCore`; use `X509` type for custom verification callback --- Sources/HTTPServer/NIOHTTPServer.swift | 22 ++++- .../NIOHTTPServerConfiguration.swift | 82 ++++++++++++++----- Sources/HTTPServer/SocketAddress.swift | 2 - .../HTTPServerTests/NIOHTTPServerTests.swift | 4 +- 4 files changed, 83 insertions(+), 27 deletions(-) diff --git a/Sources/HTTPServer/NIOHTTPServer.swift b/Sources/HTTPServer/NIOHTTPServer.swift index 8475a47..2ecf6c5 100644 --- a/Sources/HTTPServer/NIOHTTPServer.swift +++ b/Sources/HTTPServer/NIOHTTPServer.swift @@ -502,7 +502,27 @@ extension NIOHTTPServer { return try NIOSSLServerHandler( context: .init(configuration: tlsConfiguration), customVerificationCallbackWithMetadata: { certificates, promise in - customVerificationCallback(certificates, promise) + promise.completeWithTask { + // Convert input [NIOSSLCertificate] to [X509.Certificate] + let x509Certs = try certificates.map { try Certificate($0) } + + let callbackResult = try await customVerificationCallback(x509Certs) + + switch callbackResult { + case .certificateVerified(let verificationMetadata): + guard let peerChain = verificationMetadata.validatedCertificateChain else { + return .certificateVerified(.init(nil)) + } + + // Convert the result into [NIOSSLCertificate] + let nioSSLCerts = try peerChain.map { try NIOSSLCertificate($0) } + return .certificateVerified(.init(.init(nioSSLCerts))) + + case .failed(let error): + self.logger.error("Custom certificate verification failed: \(error)") + return .failed + } + } } ) } else { diff --git a/Sources/HTTPServer/NIOHTTPServerConfiguration.swift b/Sources/HTTPServer/NIOHTTPServerConfiguration.swift index 99e6fc7..fd2e371 100644 --- a/Sources/HTTPServer/NIOHTTPServerConfiguration.swift +++ b/Sources/HTTPServer/NIOHTTPServerConfiguration.swift @@ -12,8 +12,8 @@ //===----------------------------------------------------------------------===// public import NIOCertificateReloading -public import NIOCore -public import NIOSSL +import NIOCore +import NIOSSL public import X509 /// Configuration settings for ``NIOHTTPServer``. @@ -54,26 +54,6 @@ public struct NIOHTTPServerConfiguration: Sendable { /// Provides options for running the server with or without TLS encryption. /// When using TLS, you must either provide a certificate chain and private key, or a `CertificateReloader`. public struct TransportSecurity: Sendable { - /// A callback that replaces `NIOSSL`'s default certificate verification with custom verification logic. - /// - /// This is just a `Sendable` version of `NIOSSLCustomVerificationCallbackWithMetadata`. - /// - /// ## Usage - /// - /// The callback receives: - /// - **certificates**: The certificates presented by the peer. You are responsible for building and validating - /// a chain of trust from these certificates. - /// - **promise**: A promise that must be completed. Call `promise.succeed(...)` with the subset of certificates - /// that formed the validated chain of trust, or `promise.fail()` if verification fails. - /// - /// - Warning: This callback completely replaces NIOSSL's certificate verification logic and must be used with - /// caution. - public typealias CustomCertificateVerificationCallback = - @Sendable ( - [NIOSSLCertificate], - EventLoopPromise - ) -> Void - enum Backing { case plaintext case tls( @@ -258,3 +238,61 @@ public struct NIOHTTPServerConfiguration: Sendable { self.http2 = http2 } } + +@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) +extension NIOHTTPServerConfiguration.TransportSecurity { + /// Represents the outcome of certificate verification. + /// + /// Indicates whether certificate verification succeeded or failed, and provides associated metadata when + /// verification is successful. + public enum CertificateVerificationResult: Sendable, Hashable { + /// An error representing certificate verification failure. + public struct VerificationError: Swift.Error, Hashable { + let description: String + + /// Creates a verification error with a description of the failure. + /// - Parameter description: A description of why certificate verification failed. + public init(description: String) { + self.description = description + } + } + + /// Metadata resulting from successful certificate verification. + public struct VerificationMetadata: Sendable, Hashable { + /// A container for the validated certificate chain: an array of certificates forming a verified and ordered + /// chain of trust, starting from the peer's leaf certificate to a trusted root certificate. + public var validatedCertificateChain: X509.ValidatedCertificateChain? + + /// Creates an instance with the peer's *validated* certificate chain. + /// + /// - Parameter validatedCertificateChain: An optional *validated* certificate chain. If provided, it must + /// **only** contain the **validated** chain of trust that was built and verified from the certificates + /// presented by the peer. + public init(_ validatedCertificateChain: X509.ValidatedCertificateChain?) { + self.validatedCertificateChain = validatedCertificateChain + } + } + + /// Certificate verification succeeded. + /// + /// The associated metadata contains information captured during verification. + case certificateVerified(VerificationMetadata) + + /// Certificate verification failed. + case failed(VerificationError) + } + + /// A callback for implementing custom certificate verification logic. + /// + /// Use this callback to perform custom certificate verification. The callback receives the certificates presented + /// by the peer as `[X509.Certificate]`. Within the callback, you must validate these certificates against your + /// trust roots and derive a validated chain of trust per [RFC 4158](https://datatracker.ietf.org/doc/html/rfc4158). + /// + /// Return ``CertificateVerificationResult/certificateVerified(_:)`` if verification succeeds, optionally including + /// the validated certificate chain you derived. Returning the validated certificate chain allows ``NIOHTTPServer`` + /// to provide access to it in the request handler through ``NIOHTTPServer/ConnectionContext/peerCertificateChain``, + /// accessed via the task-local ``NIOHTTPServer/connectionContext`` property. Otherwise, return + /// ``CertificateVerificationResult/failed(_:)`` if verification fails. + public typealias CustomCertificateVerificationCallback = + @Sendable ([Certificate]) async throws -> CertificateVerificationResult +} diff --git a/Sources/HTTPServer/SocketAddress.swift b/Sources/HTTPServer/SocketAddress.swift index 218df15..3b56809 100644 --- a/Sources/HTTPServer/SocketAddress.swift +++ b/Sources/HTTPServer/SocketAddress.swift @@ -11,8 +11,6 @@ // //===----------------------------------------------------------------------===// -import NIOCore - /// Represents an IPv4 address. public struct IPv4: Hashable, Sendable { /// The resolved host address. diff --git a/Tests/HTTPServerTests/NIOHTTPServerTests.swift b/Tests/HTTPServerTests/NIOHTTPServerTests.swift index ad3538d..468f5b9 100644 --- a/Tests/HTTPServerTests/NIOHTTPServerTests.swift +++ b/Tests/HTTPServerTests/NIOHTTPServerTests.swift @@ -145,9 +145,9 @@ struct NIOHTTPServerTests { certificateChain: [serverChain.leaf], privateKey: serverChain.privateKey, trustRoots: [clientChain.ca], - customCertificateVerificationCallback: { certificates, promise in + customCertificateVerificationCallback: { certificates in // Return the peer's certificate chain; this must then be accessible in the request handler - promise.succeed(.certificateVerified(.init(.init(certificates)))) + .certificateVerified(.init(.init(uncheckedCertificateChain: certificates))) } ) ) From 5ecc6221204493f9a14a7bbb554daefa47eb7832 Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Wed, 10 Dec 2025 16:23:08 +0000 Subject: [PATCH 07/11] Add support for configuring verification behaviour. --- Sources/HTTPServer/NIOHTTPServer.swift | 12 +-- .../NIOHTTPServerConfiguration.swift | 80 +++++++++++++++---- 2 files changed, 71 insertions(+), 21 deletions(-) diff --git a/Sources/HTTPServer/NIOHTTPServer.swift b/Sources/HTTPServer/NIOHTTPServer.swift index 2ecf6c5..c185722 100644 --- a/Sources/HTTPServer/NIOHTTPServer.swift +++ b/Sources/HTTPServer/NIOHTTPServer.swift @@ -101,9 +101,9 @@ public struct NIOHTTPServer: HTTPServerProtocol { } /// The peer's validated certificate chain. This returns `nil` if a - /// ``NIOHTTPServerConfiguration/TransportSecurity/CustomCertificateVerificationCallback`` was not set in the - /// ``NIOHTTPServerConfiguration/TransportSecurity`` property of the server configuration, or if the peer did - /// not authenticate with certificates. + /// ``NIOHTTPServerConfiguration/TransportSecurity/CustomCertificateVerificationCallback`` was not set when + /// configuring mTLS in the server configuration, or if the custom verification callback did not return the + /// derived validated chain. public var peerCertificateChain: X509.ValidatedCertificateChain? { get async throws { if let certs = try await self.peerCertificateChainFuture?.get() { @@ -235,7 +235,7 @@ public struct NIOHTTPServer: HTTPServerProtocol { http2Configuration: http2Config ) - case .mTLS(let certificateChain, let privateKey, let trustRoots, let verificationCallback): + case .mTLS(let certificateChain, let privateKey, let trustRoots, let verificationMode, let verificationCallback): let http2Config = NIOHTTP2Handler.Configuration( httpServerHTTP2Configuration: configuration.http2 ) @@ -249,6 +249,7 @@ public struct NIOHTTPServer: HTTPServerProtocol { privateKey: privateKey, trustRoots: nioTrustRoots ) + tlsConfiguration.certificateVerification = .init(verificationMode) tlsConfiguration.applicationProtocols = ["h2", "http/1.1"] try await self.serveSecureUpgrade( @@ -260,7 +261,7 @@ public struct NIOHTTPServer: HTTPServerProtocol { verificationCallback: verificationCallback ) - case .reloadingMTLS(let certificateReloader, let trustRoots, let verificationCallback): + case .reloadingMTLS(let certificateReloader, let trustRoots, let verificationMode, let verificationCallback): let http2Config = NIOHTTP2Handler.Configuration( httpServerHTTP2Configuration: configuration.http2 ) @@ -271,6 +272,7 @@ public struct NIOHTTPServer: HTTPServerProtocol { certificateReloader: certificateReloader, trustRoots: nioTrustRoots ) + tlsConfiguration.certificateVerification = .init(verificationMode) tlsConfiguration.applicationProtocols = ["h2", "http/1.1"] try await self.serveSecureUpgrade( diff --git a/Sources/HTTPServer/NIOHTTPServerConfiguration.swift b/Sources/HTTPServer/NIOHTTPServerConfiguration.swift index fd2e371..9182469 100644 --- a/Sources/HTTPServer/NIOHTTPServerConfiguration.swift +++ b/Sources/HTTPServer/NIOHTTPServerConfiguration.swift @@ -65,11 +65,13 @@ public struct NIOHTTPServerConfiguration: Sendable { certificateChain: [Certificate], privateKey: Certificate.PrivateKey, trustRoots: [Certificate]?, + certificateVerification: CertificateVerification = .noHostnameVerification, customCertificateVerificationCallback: CustomCertificateVerificationCallback? = nil ) case reloadingMTLS( certificateReloader: any CertificateReloader, trustRoots: [Certificate]?, + certificateVerification: CertificateVerification = .noHostnameVerification, customCertificateVerificationCallback: CustomCertificateVerificationCallback? = nil ) } @@ -109,12 +111,15 @@ public struct NIOHTTPServerConfiguration: Sendable { /// - certificateChain: The certificate chain to present during negotiation. /// - privateKey: The private key corresponding to the leaf certificate in `certificateChain`. /// - trustRoots: The root certificates to trust when verifying client certificates. + /// - certificateVerification: Configures the client certificate validation behaviour. Defaults to + /// ``CertificateVerification/noHostnameVerification``. /// - customCertificateVerificationCallback: A custom certificate verification callback. This will override /// NIOSSL's default certificate verification logic. public static func mTLS( certificateChain: [Certificate], privateKey: Certificate.PrivateKey, trustRoots: [Certificate]?, + certificateVerification: CertificateVerification = .noHostnameVerification, customCertificateVerificationCallback: CustomCertificateVerificationCallback? = nil ) -> Self { Self( @@ -134,11 +139,14 @@ public struct NIOHTTPServerConfiguration: Sendable { /// - Parameters: /// - certificateReloader: The certificate reloader instance. /// - trustRoots: The root certificates to trust when verifying client certificates. + /// - certificateVerification: Configures the client certificate validation behaviour. Defaults to + // ``CertificateVerification/noHostnameVerification``. /// - customCertificateVerificationCallback: A custom certificate verification callback. This will override /// NIOSSL's default certificate verification logic. public static func mTLS( certificateReloader: any CertificateReloader, trustRoots: [Certificate]?, + certificateVerification: CertificateVerification = .noHostnameVerification, customCertificateVerificationCallback: CustomCertificateVerificationCallback? = nil ) throws -> Self { Self( @@ -245,18 +253,7 @@ extension NIOHTTPServerConfiguration.TransportSecurity { /// /// Indicates whether certificate verification succeeded or failed, and provides associated metadata when /// verification is successful. - public enum CertificateVerificationResult: Sendable, Hashable { - /// An error representing certificate verification failure. - public struct VerificationError: Swift.Error, Hashable { - let description: String - - /// Creates a verification error with a description of the failure. - /// - Parameter description: A description of why certificate verification failed. - public init(description: String) { - self.description = description - } - } - + public enum VerificationResult: Sendable, Hashable { /// Metadata resulting from successful certificate verification. public struct VerificationMetadata: Sendable, Hashable { /// A container for the validated certificate chain: an array of certificates forming a verified and ordered @@ -273,6 +270,17 @@ extension NIOHTTPServerConfiguration.TransportSecurity { } } + /// An error representing certificate verification failure. + public struct VerificationError: Swift.Error, Hashable { + public let reason: String + + /// Creates a verification error with the reason why verification failed. + /// - Parameter reason: The reason of why certificate verification failed. + public init(reason: String) { + self.reason = reason + } + } + /// Certificate verification succeeded. /// /// The associated metadata contains information captured during verification. @@ -288,11 +296,51 @@ extension NIOHTTPServerConfiguration.TransportSecurity { /// by the peer as `[X509.Certificate]`. Within the callback, you must validate these certificates against your /// trust roots and derive a validated chain of trust per [RFC 4158](https://datatracker.ietf.org/doc/html/rfc4158). /// - /// Return ``CertificateVerificationResult/certificateVerified(_:)`` if verification succeeds, optionally including + /// Return ``VerificationResult/certificateVerified(_:)`` if verification succeeds, optionally including /// the validated certificate chain you derived. Returning the validated certificate chain allows ``NIOHTTPServer`` /// to provide access to it in the request handler through ``NIOHTTPServer/ConnectionContext/peerCertificateChain``, /// accessed via the task-local ``NIOHTTPServer/connectionContext`` property. Otherwise, return - /// ``CertificateVerificationResult/failed(_:)`` if verification fails. - public typealias CustomCertificateVerificationCallback = - @Sendable ([Certificate]) async throws -> CertificateVerificationResult + /// ``VerificationResult/failed(_:)`` if verification fails. + public typealias CustomCertificateVerificationCallback = @Sendable ([Certificate]) async throws -> VerificationResult +} + +@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) +extension NIOHTTPServerConfiguration.TransportSecurity { + /// Represents the certificate verification behaviour. + public struct CertificateVerification: Sendable { + enum VerificationMode { + case optionalVerification + case noHostnameVerification + } + + let mode: VerificationMode + + /// Allows peers to connect without presenting any certificates. However, if the peer *does* present + /// certificates, they are validated like normal (exactly like with ``noHostnameVerification``), and the TLS + /// handshake will fail if verification fails. + /// + /// - Warning: With this mode, a peer can successfully connect even without presenting any certificates. As such, + /// this mode must be used with great caution. + public static var optionalVerification: Self { + Self(mode: .optionalVerification) + } + + /// Validates the certificates presented by the peer but skips hostname verification as it cannot succeed in + /// a server context. + public static var noHostnameVerification: Self { + Self(mode: .noHostnameVerification) + } + } +} + +@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) +extension NIOSSL.CertificateVerification { + init(_ verificationMode: NIOHTTPServerConfiguration.TransportSecurity.CertificateVerification) { + switch verificationMode.mode { + case .noHostnameVerification: + self = .noHostnameVerification + case .optionalVerification: + self = .optionalVerification + } + } } From 6686bbe43c08c43b52149a3d0b9d439117e89dc8 Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Wed, 10 Dec 2025 17:01:06 +0000 Subject: [PATCH 08/11] Remove callback typealias; revise type names --- Sources/HTTPServer/NIOHTTPServer.swift | 9 +- .../NIOHTTPServerConfiguration.swift | 181 +++++++++--------- 2 files changed, 94 insertions(+), 96 deletions(-) diff --git a/Sources/HTTPServer/NIOHTTPServer.swift b/Sources/HTTPServer/NIOHTTPServer.swift index c185722..0293b20 100644 --- a/Sources/HTTPServer/NIOHTTPServer.swift +++ b/Sources/HTTPServer/NIOHTTPServer.swift @@ -100,9 +100,8 @@ public struct NIOHTTPServer: HTTPServerProtocol { self.peerCertificateChainFuture = peerCertificateChainFuture } - /// The peer's validated certificate chain. This returns `nil` if a - /// ``NIOHTTPServerConfiguration/TransportSecurity/CustomCertificateVerificationCallback`` was not set when - /// configuring mTLS in the server configuration, or if the custom verification callback did not return the + /// The peer's validated certificate chain. This returns `nil` if a custom verification callback was not set + /// when configuring mTLS in the server configuration, or if the custom verification callback did not return the /// derived validated chain. public var peerCertificateChain: X509.ValidatedCertificateChain? { get async throws { @@ -328,7 +327,7 @@ public struct NIOHTTPServer: HTTPServerProtocol { handler: some HTTPServerRequestHandler, asyncChannelConfiguration: NIOAsyncChannel.Configuration, http2Configuration: NIOHTTP2Handler.Configuration, - verificationCallback: NIOHTTPServerConfiguration.TransportSecurity.CustomCertificateVerificationCallback? = nil + verificationCallback: (@Sendable ([X509.Certificate]) async throws -> CertificateVerificationResult)? = nil ) async throws { switch bindTarget.backing { case .hostAndPort(let host, let port): @@ -498,7 +497,7 @@ public struct NIOHTTPServer: HTTPServerProtocol { extension NIOHTTPServer { fileprivate func makeSSLServerHandler( _ tlsConfiguration: TLSConfiguration, - _ customVerificationCallback: NIOHTTPServerConfiguration.TransportSecurity.CustomCertificateVerificationCallback? + _ customVerificationCallback: (@Sendable ([X509.Certificate]) async throws -> CertificateVerificationResult)? ) throws -> NIOSSLServerHandler { if let customVerificationCallback { return try NIOSSLServerHandler( diff --git a/Sources/HTTPServer/NIOHTTPServerConfiguration.swift b/Sources/HTTPServer/NIOHTTPServerConfiguration.swift index 9182469..1f0b417 100644 --- a/Sources/HTTPServer/NIOHTTPServerConfiguration.swift +++ b/Sources/HTTPServer/NIOHTTPServerConfiguration.swift @@ -65,14 +65,14 @@ public struct NIOHTTPServerConfiguration: Sendable { certificateChain: [Certificate], privateKey: Certificate.PrivateKey, trustRoots: [Certificate]?, - certificateVerification: CertificateVerification = .noHostnameVerification, - customCertificateVerificationCallback: CustomCertificateVerificationCallback? = nil + certificateVerification: CertificateVerificationMode = .noHostnameVerification, + customCertificateVerificationCallback: (@Sendable ([X509.Certificate]) async throws -> CertificateVerificationResult)? = nil ) case reloadingMTLS( certificateReloader: any CertificateReloader, trustRoots: [Certificate]?, - certificateVerification: CertificateVerification = .noHostnameVerification, - customCertificateVerificationCallback: CustomCertificateVerificationCallback? = nil + certificateVerification: CertificateVerificationMode = .noHostnameVerification, + customCertificateVerificationCallback: (@Sendable ([X509.Certificate]) async throws -> CertificateVerificationResult)? = nil ) } @@ -103,24 +103,35 @@ public struct NIOHTTPServerConfiguration: Sendable { Self(backing: .reloadingTLS(certificateReloader: certificateReloader)) } - /// Enables mTLS. Optionally provide a custom verification callback to override the default verification logic - /// used to verify client certificates, and control the derivation of a validated chain of trust from the - /// certificates presented by the peer. + /// Enables mTLS. /// /// - Parameters: /// - certificateChain: The certificate chain to present during negotiation. /// - privateKey: The private key corresponding to the leaf certificate in `certificateChain`. /// - trustRoots: The root certificates to trust when verifying client certificates. /// - certificateVerification: Configures the client certificate validation behaviour. Defaults to - /// ``CertificateVerification/noHostnameVerification``. - /// - customCertificateVerificationCallback: A custom certificate verification callback. This will override - /// NIOSSL's default certificate verification logic. + /// ``CertificateVerificationMode/noHostnameVerification``. + /// - customCertificateVerificationCallback: If specified, this callback *overrides* the default NIOSSL client + /// certificate verification logic. The callback receives the certificates presented by the peer. Within the + /// callback, you must validate these certificates against your trust roots and derive a validated chain of + /// trust per [RFC 4158](https://datatracker.ietf.org/doc/html/rfc4158). Return + /// ``CertificateVerificationResult/certificateVerified(_:)`` from the callback if verification succeeds, + /// optionally including the validated certificate chain you derived. Returning the validated certificate + /// chain allows ``NIOHTTPServer`` to provide access to it in the request handler through + /// ``NIOHTTPServer/ConnectionContext/peerCertificateChain``, accessed via the task-local + /// ``NIOHTTPServer/connectionContext`` property. Otherwise, return + /// ``CertificateVerificationResult/failed(_:)`` if verification fails. + /// + /// - Warning: If `customCertificateVerificationCallback` is set, it will **override** NIOSSL's default + /// certificate verification logic. public static func mTLS( certificateChain: [Certificate], privateKey: Certificate.PrivateKey, trustRoots: [Certificate]?, - certificateVerification: CertificateVerification = .noHostnameVerification, - customCertificateVerificationCallback: CustomCertificateVerificationCallback? = nil + certificateVerification: CertificateVerificationMode = .noHostnameVerification, + customCertificateVerificationCallback: ( + @Sendable ([X509.Certificate]) async throws -> CertificateVerificationResult + )? = nil ) -> Self { Self( backing: .mTLS( @@ -132,22 +143,27 @@ public struct NIOHTTPServerConfiguration: Sendable { ) } - /// Enables mTLS with certificate reloading. Optionally provide a custom verification callback to override the default verification logic - /// used to verify client certificates, and control the derivation of a validated chain of trust from the - /// certificates presented by the peer. + /// Enables mTLS with certificate reloading. /// /// - Parameters: /// - certificateReloader: The certificate reloader instance. /// - trustRoots: The root certificates to trust when verifying client certificates. /// - certificateVerification: Configures the client certificate validation behaviour. Defaults to - // ``CertificateVerification/noHostnameVerification``. - /// - customCertificateVerificationCallback: A custom certificate verification callback. This will override - /// NIOSSL's default certificate verification logic. + /// ``CertificateVerification/noHostnameVerification``. + /// - customCertificateVerificationCallback: If specified, this callback *overrides* the default NIOSSL client + /// certificate verification logic. Refer to the documentation for this argument in + /// ``mTLS(certificateChain:privateKey:trustRoots:certificateVerification:customCertificateVerificationCallback:)`` + /// for more details. + /// + /// - Warning: If `customCertificateVerificationCallback` is set, it will **override** NIOSSL's default + /// certificate verification logic. public static func mTLS( certificateReloader: any CertificateReloader, trustRoots: [Certificate]?, - certificateVerification: CertificateVerification = .noHostnameVerification, - customCertificateVerificationCallback: CustomCertificateVerificationCallback? = nil + certificateVerification: CertificateVerificationMode = .noHostnameVerification, + customCertificateVerificationCallback: ( + @Sendable ([X509.Certificate]) async throws -> CertificateVerificationResult + )? = nil ) throws -> Self { Self( backing: .reloadingMTLS( @@ -247,95 +263,78 @@ public struct NIOHTTPServerConfiguration: Sendable { } } +/// Represents the outcome of certificate verification. +/// +/// Indicates whether certificate verification succeeded or failed, and provides associated metadata when verification +/// is successful. @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) -extension NIOHTTPServerConfiguration.TransportSecurity { - /// Represents the outcome of certificate verification. - /// - /// Indicates whether certificate verification succeeded or failed, and provides associated metadata when - /// verification is successful. - public enum VerificationResult: Sendable, Hashable { - /// Metadata resulting from successful certificate verification. - public struct VerificationMetadata: Sendable, Hashable { - /// A container for the validated certificate chain: an array of certificates forming a verified and ordered - /// chain of trust, starting from the peer's leaf certificate to a trusted root certificate. - public var validatedCertificateChain: X509.ValidatedCertificateChain? - - /// Creates an instance with the peer's *validated* certificate chain. - /// - /// - Parameter validatedCertificateChain: An optional *validated* certificate chain. If provided, it must - /// **only** contain the **validated** chain of trust that was built and verified from the certificates - /// presented by the peer. - public init(_ validatedCertificateChain: X509.ValidatedCertificateChain?) { - self.validatedCertificateChain = validatedCertificateChain - } +public enum CertificateVerificationResult: Sendable, Hashable { + /// Metadata resulting from successful certificate verification. + public struct VerificationMetadata: Sendable, Hashable { + /// A container for the validated certificate chain: an array of certificates forming a verified and ordered + /// chain of trust, starting from the peer's leaf certificate to a trusted root certificate. + public var validatedCertificateChain: X509.ValidatedCertificateChain? + + /// Creates an instance with the peer's *validated* certificate chain. + /// + /// - Parameter validatedCertificateChain: An optional *validated* certificate chain. If provided, it must + /// **only** contain the **validated** chain of trust that was built and verified from the certificates + /// presented by the peer. + public init(_ validatedCertificateChain: X509.ValidatedCertificateChain?) { + self.validatedCertificateChain = validatedCertificateChain } + } - /// An error representing certificate verification failure. - public struct VerificationError: Swift.Error, Hashable { - public let reason: String + /// An error representing certificate verification failure. + public struct VerificationError: Swift.Error, Hashable { + public let reason: String - /// Creates a verification error with the reason why verification failed. - /// - Parameter reason: The reason of why certificate verification failed. - public init(reason: String) { - self.reason = reason - } + /// Creates a verification error with the reason why verification failed. + /// - Parameter reason: The reason of why certificate verification failed. + public init(reason: String) { + self.reason = reason } - - /// Certificate verification succeeded. - /// - /// The associated metadata contains information captured during verification. - case certificateVerified(VerificationMetadata) - - /// Certificate verification failed. - case failed(VerificationError) } - /// A callback for implementing custom certificate verification logic. - /// - /// Use this callback to perform custom certificate verification. The callback receives the certificates presented - /// by the peer as `[X509.Certificate]`. Within the callback, you must validate these certificates against your - /// trust roots and derive a validated chain of trust per [RFC 4158](https://datatracker.ietf.org/doc/html/rfc4158). + /// Certificate verification succeeded. /// - /// Return ``VerificationResult/certificateVerified(_:)`` if verification succeeds, optionally including - /// the validated certificate chain you derived. Returning the validated certificate chain allows ``NIOHTTPServer`` - /// to provide access to it in the request handler through ``NIOHTTPServer/ConnectionContext/peerCertificateChain``, - /// accessed via the task-local ``NIOHTTPServer/connectionContext`` property. Otherwise, return - /// ``VerificationResult/failed(_:)`` if verification fails. - public typealias CustomCertificateVerificationCallback = @Sendable ([Certificate]) async throws -> VerificationResult + /// The associated metadata contains information captured during verification. + case certificateVerified(VerificationMetadata) + + /// Certificate verification failed. + case failed(VerificationError) } -@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) -extension NIOHTTPServerConfiguration.TransportSecurity { - /// Represents the certificate verification behaviour. - public struct CertificateVerification: Sendable { - enum VerificationMode { - case optionalVerification - case noHostnameVerification - } +/// Represents the certificate verification behaviour. +public struct CertificateVerificationMode: Sendable { + enum VerificationMode { + case optionalVerification + case noHostnameVerification + } - let mode: VerificationMode + let mode: VerificationMode - /// Allows peers to connect without presenting any certificates. However, if the peer *does* present - /// certificates, they are validated like normal (exactly like with ``noHostnameVerification``), and the TLS - /// handshake will fail if verification fails. - /// - /// - Warning: With this mode, a peer can successfully connect even without presenting any certificates. As such, - /// this mode must be used with great caution. - public static var optionalVerification: Self { - Self(mode: .optionalVerification) - } + /// Allows peers to connect without presenting any certificates. However, if the peer *does* present + /// certificates, they are validated like normal (exactly like with ``noHostnameVerification``), and the TLS + /// handshake will fail if verification fails. + /// + /// - Warning: With this mode, a peer can successfully connect even without presenting any certificates. As such, + /// this mode must be used with great caution. + public static var optionalVerification: Self { + Self(mode: .optionalVerification) + } - /// Validates the certificates presented by the peer but skips hostname verification as it cannot succeed in - /// a server context. - public static var noHostnameVerification: Self { - Self(mode: .noHostnameVerification) - } + /// Validates the certificates presented by the peer but skips hostname verification as it cannot succeed in + /// a server context. + public static var noHostnameVerification: Self { + Self(mode: .noHostnameVerification) } } @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) extension NIOSSL.CertificateVerification { - init(_ verificationMode: NIOHTTPServerConfiguration.TransportSecurity.CertificateVerification) { + /// Maps ``CertificateVerificationMode`` to the NIOSSL representation. + init(_ verificationMode: CertificateVerificationMode) { switch verificationMode.mode { case .noHostnameVerification: self = .noHostnameVerification From e8b6f32a56f5617a33991ac48f498fba2ef08953 Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Wed, 10 Dec 2025 17:48:53 +0000 Subject: [PATCH 09/11] Revise `TransportSecurity` documentation --- Sources/HTTPServer/NIOHTTPServerConfiguration.swift | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/Sources/HTTPServer/NIOHTTPServerConfiguration.swift b/Sources/HTTPServer/NIOHTTPServerConfiguration.swift index 1f0b417..2fa3c80 100644 --- a/Sources/HTTPServer/NIOHTTPServerConfiguration.swift +++ b/Sources/HTTPServer/NIOHTTPServerConfiguration.swift @@ -78,9 +78,10 @@ public struct NIOHTTPServerConfiguration: Sendable { let backing: Backing + /// Configures the server for plaintext HTTP without TLS encryption. public static let plaintext: Self = Self(backing: .plaintext) - /// Enables TLS. + /// Configures the server for TLS with the provided certificate chain and private key. /// - Parameters: /// - certificateChain: The certificate chain to present during negotiation. /// - privateKey: The private key corresponding to the leaf certificate in `certificateChain`. @@ -96,14 +97,14 @@ public struct NIOHTTPServerConfiguration: Sendable { ) } - /// Enables TLS with automatic certificate reloading. + /// Configures the server for TLS with automatic certificate reloading. /// - Parameters: /// - certificateReloader: The certificate reloader instance. public static func tls(certificateReloader: any CertificateReloader) throws -> Self { Self(backing: .reloadingTLS(certificateReloader: certificateReloader)) } - /// Enables mTLS. + /// Configures the server for mTLS with support for customizing client certificate verification logic. /// /// - Parameters: /// - certificateChain: The certificate chain to present during negotiation. @@ -143,7 +144,8 @@ public struct NIOHTTPServerConfiguration: Sendable { ) } - /// Enables mTLS with certificate reloading. + /// Configures the server for mTLS with automatic certificate reloading and support for customizing client + /// certificate verification logic. /// /// - Parameters: /// - certificateReloader: The certificate reloader instance. From 0b9b20331dbe1aa513bf64c389b4c1b2b9200f7c Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Mon, 15 Dec 2025 12:37:14 +0000 Subject: [PATCH 10/11] Address feedback from review --- .../NIOHTTPServer+ConnectionContext.swift | 29 +++++++++++++++ Sources/HTTPServer/NIOHTTPServer.swift | 35 ++++--------------- .../NIOHTTPServerConfiguration.swift | 12 +++++-- Sources/HTTPServer/NIOSSL+X509.swift | 12 +++---- 4 files changed, 51 insertions(+), 37 deletions(-) create mode 100644 Sources/HTTPServer/NIOHTTPServer+ConnectionContext.swift diff --git a/Sources/HTTPServer/NIOHTTPServer+ConnectionContext.swift b/Sources/HTTPServer/NIOHTTPServer+ConnectionContext.swift new file mode 100644 index 0000000..79a698e --- /dev/null +++ b/Sources/HTTPServer/NIOHTTPServer+ConnectionContext.swift @@ -0,0 +1,29 @@ +import NIOCore +import NIOSSL +public import X509 + +@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) +extension NIOHTTPServer { + /// Connection-specific information available during request handling. + /// + /// Provides access to data such as the peer's validated certificate chain. + public struct ConnectionContext: Sendable { + var peerCertificateChainFuture: EventLoopFuture? + + init(_ peerCertificateChainFuture: EventLoopFuture? = nil) { + self.peerCertificateChainFuture = peerCertificateChainFuture + } + + /// The peer's validated certificate chain. This returns `nil` if a custom verification callback was not set + /// when configuring mTLS in the server configuration, or if the custom verification callback did not return the + /// derived validated chain. + public var peerCertificateChain: X509.ValidatedCertificateChain? { + get async throws { + if let certs = try await self.peerCertificateChainFuture?.get() { + return .init(uncheckedCertificateChain: try certs.map { try Certificate($0) }) + } + return nil + } + } + } +} diff --git a/Sources/HTTPServer/NIOHTTPServer.swift b/Sources/HTTPServer/NIOHTTPServer.swift index 0293b20..5175ee9 100644 --- a/Sources/HTTPServer/NIOHTTPServer.swift +++ b/Sources/HTTPServer/NIOHTTPServer.swift @@ -25,7 +25,7 @@ import NIOPosix import NIOSSL import SwiftASN1 import Synchronization -public import X509 +import X509 /// A generic HTTP server that can handle incoming HTTP requests. /// @@ -87,32 +87,9 @@ public struct NIOHTTPServer: HTTPServerProtocol { /// Task-local storage for connection-specific information accessible from request handlers. /// - /// Use this to access data such as the peer's validated certificate chain. + /// - SeeAlso: ``ConnectionContext``. @TaskLocal public static var connectionContext = ConnectionContext() - /// Connection-specific information available during request handling. - /// - /// Provides access to data such as the peer's validated certificate chain. - public struct ConnectionContext: Sendable { - var peerCertificateChainFuture: EventLoopFuture? - - init(_ peerCertificateChainFuture: EventLoopFuture? = nil) { - self.peerCertificateChainFuture = peerCertificateChainFuture - } - - /// The peer's validated certificate chain. This returns `nil` if a custom verification callback was not set - /// when configuring mTLS in the server configuration, or if the custom verification callback did not return the - /// derived validated chain. - public var peerCertificateChain: X509.ValidatedCertificateChain? { - get async throws { - if let certs = try await self.peerCertificateChainFuture?.get() { - return .init(uncheckedCertificateChain: try certs.map { try Certificate($0) }) - } - return nil - } - } - } - /// Create a new ``HTTPServer`` implemented over `SwiftNIO`. /// - Parameters: /// - logger: A logger instance for recording server events and debugging information. @@ -387,8 +364,8 @@ public struct NIOHTTPServer: HTTPServerProtocol { case .http2((let http2Connection, let http2Multiplexer)): do { let chainFuture = http2Connection.nioSSL_peerValidatedCertificateChain() - for try await http2StreamChannel in http2Multiplexer.inbound { - Self.$connectionContext.withValue(ConnectionContext(chainFuture)) { + try await Self.$connectionContext.withValue(ConnectionContext(chainFuture)) { + for try await http2StreamChannel in http2Multiplexer.inbound { connectionGroup.addTask { try await self.handleRequestChannel( channel: http2StreamChannel, @@ -520,7 +497,9 @@ extension NIOHTTPServer { return .certificateVerified(.init(.init(nioSSLCerts))) case .failed(let error): - self.logger.error("Custom certificate verification failed: \(error)") + self.logger.error("Custom certificate verification failed", metadata: [ + "failure-reason": .string(error.reason) + ]) return .failed } } diff --git a/Sources/HTTPServer/NIOHTTPServerConfiguration.swift b/Sources/HTTPServer/NIOHTTPServerConfiguration.swift index 2fa3c80..c41b109 100644 --- a/Sources/HTTPServer/NIOHTTPServerConfiguration.swift +++ b/Sources/HTTPServer/NIOHTTPServerConfiguration.swift @@ -153,9 +153,15 @@ public struct NIOHTTPServerConfiguration: Sendable { /// - certificateVerification: Configures the client certificate validation behaviour. Defaults to /// ``CertificateVerification/noHostnameVerification``. /// - customCertificateVerificationCallback: If specified, this callback *overrides* the default NIOSSL client - /// certificate verification logic. Refer to the documentation for this argument in - /// ``mTLS(certificateChain:privateKey:trustRoots:certificateVerification:customCertificateVerificationCallback:)`` - /// for more details. + /// certificate verification logic. The callback receives the certificates presented by the peer. Within the + /// callback, you must validate these certificates against your trust roots and derive a validated chain of + /// trust per [RFC 4158](https://datatracker.ietf.org/doc/html/rfc4158). Return + /// ``CertificateVerificationResult/certificateVerified(_:)`` from the callback if verification succeeds, + /// optionally including the validated certificate chain you derived. Returning the validated certificate + /// chain allows ``NIOHTTPServer`` to provide access to it in the request handler through + /// ``NIOHTTPServer/ConnectionContext/peerCertificateChain``, accessed via the task-local + /// ``NIOHTTPServer/connectionContext`` property. Otherwise, return + /// ``CertificateVerificationResult/failed(_:)`` if verification fails. /// /// - Warning: If `customCertificateVerificationCallback` is set, it will **override** NIOSSL's default /// certificate verification logic. diff --git a/Sources/HTTPServer/NIOSSL+X509.swift b/Sources/HTTPServer/NIOSSL+X509.swift index 1048691..863b6db 100644 --- a/Sources/HTTPServer/NIOSSL+X509.swift +++ b/Sources/HTTPServer/NIOSSL+X509.swift @@ -19,7 +19,7 @@ import X509 // MARK: X509 to NIOSSL -@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) +@available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, visionOS 1.0, *) extension NIOSSLCertificate { convenience init(_ certificate: Certificate) throws { var serializer = DER.Serializer() @@ -28,28 +28,28 @@ extension NIOSSLCertificate { } } -@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) +@available(macOS 11.0, iOS 14, tvOS 14, watchOS 7, macCatalyst 14, visionOS 1.0, *) extension NIOSSLPrivateKey { convenience init(_ privateKey: Certificate.PrivateKey) throws { try self.init(bytes: try privateKey.serializeAsPEM().derBytes, format: .der) } } -@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) +@available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, visionOS 1.0, *) extension NIOSSLCertificateSource { init(_ certificate: Certificate) throws { self = .certificate(try NIOSSLCertificate(certificate)) } } -@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) +@available(macOS 11.0, iOS 14, tvOS 14, watchOS 7, macCatalyst 14, visionOS 1.0, *) extension NIOSSLPrivateKeySource { init(_ privateKey: Certificate.PrivateKey) throws { self = .privateKey(try NIOSSLPrivateKey(privateKey)) } } -@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) +@available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, visionOS 1.0, *) extension NIOSSLTrustRoots { init(treatingNilAsSystemTrustRoots certificates: [Certificate]?) throws { if let certificates { @@ -62,7 +62,7 @@ extension NIOSSLTrustRoots { // MARK: NIOSSL to X509 -@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) +@available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, visionOS 1.0, *) extension Certificate { init(_ certificate: NIOSSLCertificate) throws { try self.init(derEncoded: certificate.toDERBytes()) From 27799c9a29b30a7adefc30a70567393df249b800 Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Tue, 16 Dec 2025 11:20:00 +0000 Subject: [PATCH 11/11] Update tests to use trailer constant --- Tests/HTTPServerTests/NIOHTTPServerTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/HTTPServerTests/NIOHTTPServerTests.swift b/Tests/HTTPServerTests/NIOHTTPServerTests.swift index 468f5b9..683e0f8 100644 --- a/Tests/HTTPServerTests/NIOHTTPServerTests.swift +++ b/Tests/HTTPServerTests/NIOHTTPServerTests.swift @@ -104,7 +104,7 @@ struct NIOHTTPServerTests { try await responseBodySender.produceAndConclude { responseBodyWriter in var responseBodyWriter = responseBodyWriter try await responseBodyWriter.write([1, 2].span) - return [.trailer: "test_trailer"] + return Self.trailer } } } @@ -181,7 +181,7 @@ struct NIOHTTPServerTests { try await sender.produceAndConclude { bodyWriter in var bodyWriter = bodyWriter try await bodyWriter.write([1, 2].span) - return [.trailer: "test_trailer"] + return Self.trailer } } }