From cfeb07896d5d4ad576811655c033377223843699 Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Tue, 28 Jul 2026 16:48:14 +0100 Subject: [PATCH 1/4] Add support for Raw Public Key credentials --- .../ConnectionHandlerExample.swift | 34 ++-- .../HTTP3/HTTP3+QUICConfiguration.swift | 57 +++--- .../NIOHTTPServer+SwiftConfiguration.swift | 68 +++++--- .../NIOHTTPServerConfiguration.swift | 6 +- .../NIOHTTPServerConfigurationError.swift | 10 +- ...sportSecurity+MTLSTrustConfiguration.swift | 126 ++++++++------ .../TransportSecurity+NIOSSL.swift | 121 +++++++++++-- .../TransportSecurity+TLSCredentials.swift | 163 +++++++++++++++--- .../SwiftConfigurationIntegration.md | 6 +- Sources/NIOHTTPServer/NIOSSL+X509.swift | 11 -- .../RequestHandlerExample.swift | 34 ++-- .../HTTP3ConfigurationTests.swift | 8 +- .../HTTPKeepAliveHandlerTests.swift | 5 +- .../NIOHTTPServer+ServiceLifecycleTests.swift | 4 +- .../NIOHTTPServerEndToEndTests.swift | 7 +- ...NIOHTTPServerSwiftConfigurationTests.swift | 98 ++++++++--- .../NIOHTTPServerTests.swift | 20 ++- .../Utilities/Helpers.swift | 42 +++++ .../NIOClient/NIOClient+SecureUpgrade.swift | 25 +-- 19 files changed, 595 insertions(+), 250 deletions(-) diff --git a/Sources/ConnectionHandlerExample/ConnectionHandlerExample.swift b/Sources/ConnectionHandlerExample/ConnectionHandlerExample.swift index d74c4ec..ed89ad0 100644 --- a/Sources/ConnectionHandlerExample/ConnectionHandlerExample.swift +++ b/Sources/ConnectionHandlerExample/ConnectionHandlerExample.swift @@ -42,22 +42,24 @@ struct ConnectionHandlerExample { bindTarget: .hostAndPort(host: "127.0.0.1", port: 12346), supportedHTTPVersions: [.http1_1, .http2(config: .init())], transportSecurity: .tls( - credentials: .inMemory( - certificateChain: [ - try Certificate( - version: .v3, - serialNumber: .init(bytes: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]), - publicKey: .init(privateKey.publicKey), - notValidBefore: Date.now.addingTimeInterval(-60), - notValidAfter: Date.now.addingTimeInterval(60 * 60), - issuer: DistinguishedName(), - subject: DistinguishedName(), - signatureAlgorithm: .ecdsaWithSHA256, - extensions: .init(), - issuerPrivateKey: Certificate.PrivateKey(privateKey) - ) - ], - privateKey: Certificate.PrivateKey(privateKey) + credentials: .x509( + .certificates( + chain: [ + try Certificate( + version: .v3, + serialNumber: .init(bytes: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]), + publicKey: .init(privateKey.publicKey), + notValidBefore: Date.now.addingTimeInterval(-60), + notValidAfter: Date.now.addingTimeInterval(60 * 60), + issuer: DistinguishedName(), + subject: DistinguishedName(), + signatureAlgorithm: .ecdsaWithSHA256, + extensions: .init(), + issuerPrivateKey: Certificate.PrivateKey(privateKey) + ) + ], + privateKey: Certificate.PrivateKey(privateKey) + ) ) ) ) diff --git a/Sources/NIOHTTPServer/Configuration/HTTP3/HTTP3+QUICConfiguration.swift b/Sources/NIOHTTPServer/Configuration/HTTP3/HTTP3+QUICConfiguration.swift index c9db61f..c2e7d14 100644 --- a/Sources/NIOHTTPServer/Configuration/HTTP3/HTTP3+QUICConfiguration.swift +++ b/Sources/NIOHTTPServer/Configuration/HTTP3/HTTP3+QUICConfiguration.swift @@ -236,16 +236,25 @@ extension NIOQUIC.AuthenticationConfiguration { case .mTLS: throw NIOHTTPServerConfigurationError.mTLSNotCurrentlySupportedOverHTTP3 - case .tls(let credentials): - switch credentials.backing { - case .inMemory, .reloading: - throw NIOHTTPServerConfigurationError.onlyPEMFileCredentialsCurrentlySupportedOverHTTP3 - - case .pemFile(let certificateChainPath, let privateKeyPath): - self = .x509Certificates( - certificateChainFilePath: certificateChainPath, - privateKeyFilePath: privateKeyPath - ) + case .tls(let tlsCredentials): + switch tlsCredentials.backing { + case .x509(let x509Credentials): + switch x509Credentials.backing { + case .serialized(.file(let certificateChain, let privateKey, format: .pem)): + self = .x509Certificates(certificateChainFilePath: certificateChain, privateKeyFilePath: privateKey) + + case .certificates, .reloading, .serialized(.file(_, _, .der)), .serialized(.bytes): + throw NIOHTTPServerConfigurationError.onlyPEMFileCredentialsCurrentlySupportedOverHTTP3 + } + + case .rawPublicKey(let rawPublicKeyCredentials): + switch rawPublicKeyCredentials.backing { + case .file(let publicKey, let privateKey, .der): + self = .rawPublicKeys(publicKeyFilePath: publicKey, privateKeyFilePath: privateKey) + + case .file(_, _, .pem): + throw NIOHTTPServerConfigurationError.pemRawPublicKeysNotCurrentlySupported + } } } } @@ -291,26 +300,30 @@ extension NIOQUIC.Authenticator { /// /// - Throws: /// - ``NIOHTTPServerConfigurationError/incompatibleTransportSecurity`` if `transportSecurity` is `.plaintext`. - /// - ``NIOHTTPServerConfigurationError/inMemoryOrReloadingTLSCredentialsNotSupportedOverHTTP3`` if the X.509 - /// credentials are provided as in-memory `X509.Certificate`/`X509.Certificate.PrivateKey` objects or as a - /// `CertificateReloader` instance. + /// - ``NIOHTTPServerConfigurationError/http3RequiresPEMFileCertificates`` if the X.509 credentials are not + /// provided as a PEM-encoded certificate chain and private key on disk. /// - An underlying error from `Authenticator`'s initializer if the certificate chain or private key cannot be /// loaded. - convenience init(_ transportSecurity: NIOHTTPServerConfiguration.TransportSecurity) throws { + convenience init?(_ transportSecurity: NIOHTTPServerConfiguration.TransportSecurity) throws { switch transportSecurity.backing { case .plaintext: throw NIOHTTPServerConfigurationError.incompatibleTransportSecurity case .tls(let tlsCredentials), .mTLS(let tlsCredentials, _): switch tlsCredentials.backing { - case .reloading: - throw NIOHTTPServerConfigurationError.onlyPEMFileCredentialsCurrentlySupportedOverHTTP3 - - case .pemFile(let certificateChainPath, let privateKeyPath): - try self.init(certificateFilePath: certificateChainPath, privateKeyFilePath: privateKeyPath) - - case .inMemory(let certificateChain, let privateKey): - try self.init(certificates: certificateChain, privateKey: privateKey) + case .rawPublicKey: + // Public/private key paths are read directly from `QUICConfiguration.authenticationConfiguration`, so + // we return `nil` here. + return nil + + case .x509(let x509Credentials): + switch x509Credentials.backing { + case .reloading, .serialized(.bytes), .serialized(.file(_, _, .der)), .certificates: + throw NIOHTTPServerConfigurationError.onlyPEMFileCredentialsCurrentlySupportedOverHTTP3 + + case .serialized(.file(let certificateChain, let privateKey, .pem)): + try self.init(certificateFilePath: certificateChain, privateKeyFilePath: privateKey) + } } } } diff --git a/Sources/NIOHTTPServer/Configuration/NIOHTTPServer+SwiftConfiguration.swift b/Sources/NIOHTTPServer/Configuration/NIOHTTPServer+SwiftConfiguration.swift index 3d3f298..620d3d9 100644 --- a/Sources/NIOHTTPServer/Configuration/NIOHTTPServer+SwiftConfiguration.swift +++ b/Sources/NIOHTTPServer/Configuration/NIOHTTPServer+SwiftConfiguration.swift @@ -187,7 +187,7 @@ extension NIOHTTPServerConfiguration.TransportSecurity { /// - `mode` (string, required): The transport security mode for the server (permitted values: `"plaintext"`, /// `"tls"`, `"mTLS"`). /// - `credentialSource` (string, required for `"tls"` and `"mTLS"`): How TLS credentials are provided (permitted - /// values: `"inline"`, `"file"`). + /// values: `"inline"`, `"file"`, `"rawPublicKey"`). /// /// ### Configuration keys for `credentialSource: "inline"`: /// - `certificateChainPEMString` (string, required): PEM-formatted certificate chain content. @@ -199,7 +199,11 @@ extension NIOHTTPServerConfiguration.TransportSecurity { /// - `refreshInterval` (int, optional): The interval (in seconds) at which the certificate chain and private key /// will be reloaded. If omitted, credentials are loaded from the file only once at startup. /// - /// ### Configuration keys for `mode: "mTLS"`: + /// ### Configuration keys for `credentialSource: "rawPublicKey"` (only supported over HTTP/3): + /// - `publicKeyDERPath` (string, required): Path to the DER-encoded public key file. + /// - `privateKeyDERPath` (string, required): Path to the DER-encoded private key file. + /// + /// ### Configuration keys for `mode: "mTLS"` (not supported over HTTP/3): /// - `trustRootsSource` (string, required): How trust roots are provided (permitted values: `"inline"`, `"file"`, /// `"systemDefaults"`, `"customCertificateVerificationCallback"`). /// - `trustRootsPEMString` (string, required for `trustRootsSource: "inline"`): The root certificates as a @@ -257,6 +261,8 @@ extension NIOHTTPServerConfiguration.TransportSecurity.TLSCredentials { /// - When `credentialSource` is `"inline"`, the certificate chain and private key are read as PEM strings. /// - When `credentialSource` is `"file"`, the certificate chain and private key are loaded from disk, and /// optionally reloaded at a configured interval. + /// - When `credentialSource` is `"rawPublicKey"` (only supported over HTTP/3), DER-encoded public and private key + /// file paths are read. fileprivate init(config: ConfigSnapshotReader) throws { let credentialSource = try config.requiredString( forKey: "credentialSource", @@ -268,10 +274,12 @@ extension NIOHTTPServerConfiguration.TransportSecurity.TLSCredentials { let certificateChainPEMString = try config.requiredString(forKey: "certificateChainPEMString") let privateKeyPEMString = try config.requiredString(forKey: "privateKeyPEMString", isSecret: true) - self = .inMemory( - certificateChain: try PEMDocument.parseMultiple(pemString: certificateChainPEMString) - .map { try Certificate(pemEncoded: $0.pemString) }, - privateKey: try .init(pemEncoded: privateKeyPEMString) + self = .x509( + .certificates( + chain: try PEMDocument.parseMultiple(pemString: certificateChainPEMString) + .map { try Certificate(pemEncoded: $0.pemString) }, + privateKey: try .init(pemEncoded: privateKeyPEMString) + ) ) case .file: @@ -280,19 +288,28 @@ extension NIOHTTPServerConfiguration.TransportSecurity.TLSCredentials { let refreshInterval = config.int(forKey: "refreshInterval") if let refreshInterval { - self = .reloading( - certificateReloader: TimedCertificateReloader( - refreshInterval: .seconds(refreshInterval), - certificateSource: .init(location: .file(path: certificateChainPEMPath), format: .pem), - privateKeySource: .init(location: .file(path: privateKeyPEMPath), format: .pem) + self = .x509( + .reloading( + TimedCertificateReloader( + refreshInterval: .seconds(refreshInterval), + certificateSource: .init(location: .file(path: certificateChainPEMPath), format: .pem), + privateKeySource: .init(location: .file(path: privateKeyPEMPath), format: .pem) + ) ) ) } else { - self = .pemFile( - certificateChainPath: certificateChainPEMPath, - privateKeyPath: privateKeyPEMPath - ) + self = .x509(.pemFile(certificateChainPath: certificateChainPEMPath, privateKeyPath: privateKeyPEMPath)) } + + #if HTTP3 + case .rawPublicKey: + self = .rawPublicKey( + .derFile( + publicKeyPath: try config.requiredString(forKey: "publicKeyDERPath"), + privateKeyPath: try config.requiredString(forKey: "privateKeyDERPath") + ) + ) + #endif } } } @@ -338,21 +355,23 @@ extension NIOHTTPServerConfiguration.TransportSecurity.MTLSTrustConfiguration { switch trustRootsSource { case .inline: let trustRootsPEMString = try config.requiredString(forKey: "trustRootsPEMString") - self = .inMemory( - trustRoots: try PEMDocument.parseMultiple(pemString: trustRootsPEMString) - .map { try Certificate(pemEncoded: $0.pemString) }, + self.init( + .certificates( + trustRoots: try PEMDocument.parseMultiple(pemString: trustRootsPEMString) + .map { try Certificate(pemEncoded: $0.pemString) } + ), certificateVerification: .init(certificateVerificationMode) ) case .file: let trustRootsPEMPath = try config.requiredString(forKey: "trustRootsPEMPath") - self = .pemFile( - path: trustRootsPEMPath, + self.init( + .pemFile(trustRootsPath: trustRootsPEMPath), certificateVerification: .init(certificateVerificationMode) ) case .systemDefaults: - self = .systemDefaults(certificateVerification: .init(certificateVerificationMode)) + self.init(.systemDefaults, certificateVerification: .init(certificateVerificationMode)) case .customCertificateVerificationCallback: guard let customCertificateVerificationCallback else { @@ -361,8 +380,8 @@ extension NIOHTTPServerConfiguration.TransportSecurity.MTLSTrustConfiguration { throw NIOHTTPServerSwiftConfigurationError.trustRootsSourceAndVerificationCallbackMismatch } - self = .customCertificateVerificationCallback( - customCertificateVerificationCallback, + self.init( + .customCertificateVerificationCallback(customCertificateVerificationCallback), certificateVerification: .init(certificateVerificationMode) ) } @@ -418,6 +437,9 @@ extension NIOHTTPServerConfiguration.TransportSecurity { fileprivate enum CredentialSource: String { case inline case file + #if HTTP3 + case rawPublicKey + #endif } } diff --git a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift index 3e06c14..3a74de3 100644 --- a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift +++ b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift @@ -98,7 +98,7 @@ public struct NIOHTTPServerConfiguration: Sendable { /// The custom mTLS certificate verification callback, if one was configured. /// /// Returns the callback when the transport security is configured for mTLS with a - /// ``MTLSTrustConfiguration/customCertificateVerificationCallback(_:certificateVerification:)``, + /// ``MTLSTrustConfiguration/TrustSource/customCertificateVerificationCallback(_:)``, /// or `nil` otherwise. var customVerificationCallback: (@Sendable ([X509.Certificate]) async throws -> CertificateVerificationResult)? { @@ -109,11 +109,11 @@ public struct NIOHTTPServerConfiguration: Sendable { return nil case .mTLS(_, let trustRoots): - switch trustRoots.backing { + switch trustRoots.source.backing { case .customCertificateVerificationCallback(let callback): return callback - case .systemDefaults, .inMemory, .pemFile: + case .systemDefaults, .certificates, .serialized: return nil } } diff --git a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfigurationError.swift b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfigurationError.swift index 19ba873..086e93a 100644 --- a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfigurationError.swift +++ b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfigurationError.swift @@ -18,6 +18,8 @@ enum NIOHTTPServerConfigurationError: Error, CustomStringConvertible { case incompatibleTransportSecurity case noBindTargetsSpecified case onlyPEMFileCredentialsCurrentlySupportedOverHTTP3 + case rawPublicKeyTLSCredentialsNotCurrentlySupportedOverHTTP1OrHTTP2 + case pemRawPublicKeysNotCurrentlySupported // swift-nio-quic doesn't currently support mTLS. See https://github.com/apple/swift-nio-quic/issues/5. case mTLSNotCurrentlySupportedOverHTTP3 @@ -33,7 +35,13 @@ enum NIOHTTPServerConfigurationError: Error, CustomStringConvertible { "Invalid configuration: at least one bind target must be specified." case .onlyPEMFileCredentialsCurrentlySupportedOverHTTP3: - "Invalid configuration: only PEM-file X.509 credentials are supported over HTTP/3. In-memory or reloading credential sources are not currently supported." + "Invalid configuration: only PEM-file X.509 credentials are supported over HTTP/3. DER-encoded, in-memory, reloading, and PEM/DER bytes credential sources are not currently supported." + + case .rawPublicKeyTLSCredentialsNotCurrentlySupportedOverHTTP1OrHTTP2: + "Invalid configuration: raw public key TLS credentials are not currently supported over HTTP/1.1 or HTTP/2." + + case .pemRawPublicKeysNotCurrentlySupported: + "Invalid configuration: PEM-encoded raw public key credentials are not currently supported." case .mTLSNotCurrentlySupportedOverHTTP3: "Invalid configuration: mTLS is not currently supported over HTTP/3." diff --git a/Sources/NIOHTTPServer/Configuration/TransportSecurity+MTLSTrustConfiguration.swift b/Sources/NIOHTTPServer/Configuration/TransportSecurity+MTLSTrustConfiguration.swift index 764a390..d97eec1 100644 --- a/Sources/NIOHTTPServer/Configuration/TransportSecurity+MTLSTrustConfiguration.swift +++ b/Sources/NIOHTTPServer/Configuration/TransportSecurity+MTLSTrustConfiguration.swift @@ -20,84 +20,104 @@ public import X509 extension NIOHTTPServerConfiguration.TransportSecurity { /// Configures how the server verifies client certificates during mTLS. public struct MTLSTrustConfiguration: Sendable { + enum SerializedTrustRoots: Sendable { + case file(trustRootsPath: String, format: Encoding) + case bytes(trustRoots: [UInt8], format: Encoding) + } + + let source: TrustSource + let certificateVerification: CertificateVerificationMode + + /// Creates an mTLS trust configuration from a trust source and a certificate verification behavior. + /// + /// - Parameters: + /// - source: The trust roots, or the custom verification callback, used to verify the certificates presented + /// by the client. + /// - certificateVerification: The client certificate verification behavior. Defaults to + /// ``CertificateVerificationMode/noHostnameVerification``. + public init( + _ source: TrustSource, + certificateVerification: CertificateVerificationMode = .noHostnameVerification + ) { + self.source = source + self.certificateVerification = certificateVerification + } + } +} + +@available(anyAppleOS 26.0, *) +extension NIOHTTPServerConfiguration.TransportSecurity.MTLSTrustConfiguration { + public struct TrustSource: Sendable { enum Backing { case systemDefaults - case inMemory(trustRoots: [Certificate]) - case pemFile(path: String) + case certificates(trustRoots: [Certificate]) + case serialized(SerializedTrustRoots) case customCertificateVerificationCallback( @Sendable ([X509.Certificate]) async throws -> CertificateVerificationResult ) } let backing: Backing - let certificateVerification: CertificateVerificationMode /// Verifies client certificates against the operating system's default trust store. - /// - /// - Parameter certificateVerification: The client certificate verification behavior. Defaults to - /// ``CertificateVerificationMode/noHostnameVerification``. - public static func systemDefaults( - certificateVerification: CertificateVerificationMode = .noHostnameVerification - ) -> Self { - Self(backing: .systemDefaults, certificateVerification: certificateVerification) + public static var systemDefaults: Self { + Self(backing: .systemDefaults) } /// Verifies client certificates against the provided in-memory trust roots. - /// - /// - Parameters: - /// - trustRoots: The root certificates to trust when verifying client certificates. - /// - certificateVerification: The client certificate verification behavior. Defaults to - /// ``CertificateVerificationMode/noHostnameVerification``. - public static func inMemory( - trustRoots: [Certificate], - certificateVerification: CertificateVerificationMode = .noHostnameVerification - ) -> Self { - Self( - backing: .inMemory(trustRoots: trustRoots), - certificateVerification: certificateVerification - ) + public static func certificates(trustRoots: [Certificate]) -> Self { + Self(backing: .certificates(trustRoots: trustRoots)) } /// Verifies client certificates against trust roots loaded from a PEM-encoded file. /// - /// - Parameters: - /// - path: The file path to the PEM-encoded trust root certificates. - /// - certificateVerification: The client certificate verification behavior. Defaults to - /// ``CertificateVerificationMode/noHostnameVerification``. - public static func pemFile( - path: String, - certificateVerification: CertificateVerificationMode = .noHostnameVerification - ) -> Self { - Self( - backing: .pemFile(path: path), - certificateVerification: certificateVerification - ) + /// - Parameter trustRootsPath: The file path to the PEM-encoded trust root certificates. + public static func pemFile(trustRootsPath: String) -> Self { + Self(backing: .serialized(.file(trustRootsPath: trustRootsPath, format: .pem))) + } + + /// Verifies client certificates against trust roots provided as PEM-encoded bytes. + /// + /// - Parameter trustRoots: The PEM-encoded bytes of the trust root certificates. + public static func pemBytes(trustRoots: [UInt8]) -> Self { + Self(backing: .serialized(.bytes(trustRoots: trustRoots, format: .pem))) + } + + /// Verifies client certificates against trust roots loaded from a DER-encoded file. + /// + /// - Note: Only a single certificate can be encoded in the DER format. + /// + /// - Parameter trustRootPath: The file path to the DER-encoded trust root certificate. + public static func derFile(trustRootPath: String) -> Self { + Self(backing: .serialized(.file(trustRootsPath: trustRootPath, format: .der))) + } + + /// Verifies client certificates against a trust root provided as DER-encoded bytes. + /// + /// - Note: Only a single certificate can be encoded in the DER format. + /// + /// - Parameter trustRoot: The DER-encoded bytes of the trust root certificate. + public static func derBytes(trustRoot: [UInt8]) -> Self { + Self(backing: .serialized(.bytes(trustRoots: trustRoot, format: .der))) } /// Uses a custom callback to verify client certificates, overriding the default NIOSSL verification logic. /// - /// - Parameters: - /// - callback: 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/RequestContext/peerCertificateChain``. Otherwise, return - /// ``CertificateVerificationResult/failed(_:)`` if verification fails. - /// - certificateVerification: The client certificate verification behavior. Defaults to - /// ``CertificateVerificationMode/noHostnameVerification``. + /// - Parameter callback: 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/RequestContext/peerCertificateChain``. Otherwise, return + /// ``CertificateVerificationResult/failed(_:)`` if verification fails. /// /// - Warning: The provided `callback` will override NIOSSL's default certificate verification logic. public static func customCertificateVerificationCallback( - _ callback: @escaping @Sendable ([X509.Certificate]) async throws -> CertificateVerificationResult, - certificateVerification: CertificateVerificationMode = .noHostnameVerification + _ callback: @escaping @Sendable ([X509.Certificate]) async throws -> CertificateVerificationResult ) -> Self { - Self( - backing: .customCertificateVerificationCallback(callback), - certificateVerification: certificateVerification - ) + Self(backing: .customCertificateVerificationCallback(callback)) } } } diff --git a/Sources/NIOHTTPServer/Configuration/TransportSecurity+NIOSSL.swift b/Sources/NIOHTTPServer/Configuration/TransportSecurity+NIOSSL.swift index 729c105..0f5f2cd 100644 --- a/Sources/NIOHTTPServer/Configuration/TransportSecurity+NIOSSL.swift +++ b/Sources/NIOHTTPServer/Configuration/TransportSecurity+NIOSSL.swift @@ -31,33 +31,26 @@ extension NIOSSLContext { case .tls(let tlsCredentials), .mTLS(let tlsCredentials, _): switch tlsCredentials.backing { - case .inMemory(let certificateChain, let privateKey): - configuration = .makeServerConfiguration( - certificateChain: try certificateChain.map { try NIOSSLCertificateSource($0) }, - privateKey: try NIOSSLPrivateKeySource(privateKey) - ) - - case .reloading(let certificateReloader): - configuration = try .makeServerConfiguration(certificateReloader: certificateReloader) - - case .pemFile(let certificateChainPath, let privateKeyPath): - configuration = try .makeServerConfiguration( - certificateChain: NIOSSLCertificate.fromPEMFile(certificateChainPath).map { .certificate($0) }, - privateKey: .privateKey(.init(file: privateKeyPath, format: .pem)) - ) + case .x509(let x509Credentials): + configuration = try .makeServerConfiguration(x509Credentials) + + #if HTTP3 + case .rawPublicKey: + throw NIOHTTPServerConfigurationError.rawPublicKeyTLSCredentialsNotCurrentlySupportedOverHTTP1OrHTTP2 + #endif } } if case .mTLS(_, let mTLSConfiguration) = transportSecurity.backing { - switch mTLSConfiguration.backing { + switch mTLSConfiguration.source.backing { case .systemDefaults: configuration.trustRoots = .default - case .inMemory(let trustRoots): + case .certificates(let trustRoots): configuration.trustRoots = .certificates(try trustRoots.map { try NIOSSLCertificate($0) }) - case .pemFile(let path): - configuration.trustRoots = .file(path) + case .serialized(let serialized): + configuration.trustRoots = try .init(serialized) case .customCertificateVerificationCallback: // There are no trust roots when a custom certificate verification callback is specified: the callback @@ -73,3 +66,95 @@ extension NIOSSLContext { return try Self(configuration: configuration) } } + +@available(anyAppleOS 26.0, *) +extension TLSConfiguration { + /// Creates a server `TLSConfiguration` from the provided X.509 credentials. + fileprivate static func makeServerConfiguration( + _ x509Credentials: NIOHTTPServerConfiguration.TransportSecurity.X509Credentials + ) throws -> TLSConfiguration { + switch x509Credentials.backing { + case .certificates(let chain, let privateKey): + return .makeServerConfiguration( + certificateChain: try chain.map { try NIOSSLCertificateSource($0) }, + privateKey: try NIOSSLPrivateKeySource(privateKey) + ) + + case .reloading(let certificateReloader): + return try .makeServerConfiguration(certificateReloader: certificateReloader) + + case .serialized(let serialized): + return .makeServerConfiguration(certificateChain: try .init(serialized), privateKey: try .init(serialized)) + } + } +} + +@available(anyAppleOS 26.0, *) +extension [NIOSSLCertificateSource] { + fileprivate init( + _ serialized: NIOHTTPServerConfiguration.TransportSecurity.X509Credentials.SerializedCredentials + ) throws { + switch serialized { + case .file(let certificateChain, _, .pem): + self.init(try NIOSSLCertificate.fromPEMFile(certificateChain).map { .certificate($0) }) + + case .file(let certificate, _, .der): + self.init([.certificate(try NIOSSLCertificate.fromDERFile(certificate))]) + + case .bytes(let certificateChain, _, .pem): + self.init(try NIOSSLCertificate.fromPEMBytes(certificateChain).map { .certificate($0) }) + + case .bytes(let certificate, _, .der): + self.init([.certificate(try NIOSSLCertificate(bytes: certificate, format: .der))]) + } + } +} + +@available(anyAppleOS 26.0, *) +extension NIOSSLPrivateKeySource { + fileprivate init( + _ serialized: NIOHTTPServerConfiguration.TransportSecurity.X509Credentials.SerializedCredentials + ) throws { + switch serialized { + case .file(_, let privateKey, let format): + self = .privateKey(try .init(file: privateKey, format: .init(format))) + + case .bytes(_, let privateKey, let format): + self = .privateKey(try .init(bytes: privateKey, format: .init(format))) + } + } +} + +@available(anyAppleOS 26.0, *) +extension NIOSSLTrustRoots { + fileprivate init( + _ serialized: NIOHTTPServerConfiguration.TransportSecurity.MTLSTrustConfiguration.SerializedTrustRoots + ) throws { + switch serialized { + case .file(let trustRoots, .pem): + self = .file(trustRoots) + + case .file(let trustRoot, .der): + self = .certificates([try NIOSSLCertificate.fromDERFile(trustRoot)]) + + case .bytes(let trustRoots, .pem): + self = .certificates(try NIOSSLCertificate.fromPEMBytes(trustRoots)) + + case .bytes(let trustRoot, .der): + self = .certificates([try NIOSSLCertificate(bytes: trustRoot, format: .der)]) + } + } +} + +@available(anyAppleOS 26.0, *) +extension NIOSSLSerializationFormats { + fileprivate init(_ format: NIOHTTPServerConfiguration.TransportSecurity.Encoding) { + switch format { + case .pem: + self = .pem + + case .der: + self = .der + } + } +} diff --git a/Sources/NIOHTTPServer/Configuration/TransportSecurity+TLSCredentials.swift b/Sources/NIOHTTPServer/Configuration/TransportSecurity+TLSCredentials.swift index 735913e..2d8b2f7 100644 --- a/Sources/NIOHTTPServer/Configuration/TransportSecurity+TLSCredentials.swift +++ b/Sources/NIOHTTPServer/Configuration/TransportSecurity+TLSCredentials.swift @@ -17,44 +17,163 @@ public import X509 @available(anyAppleOS 26.0, *) extension NIOHTTPServerConfiguration.TransportSecurity { - /// Represents the server's TLS credentials: a certificate chain and its corresponding private key. + /// Represents the credentials the server uses to prove its identity during the TLS handshake. /// - /// Credentials can be provided as in-memory objects, loaded from PEM files on disk, or automatically reloaded at - /// runtime using a `CertificateReloader`. + /// - Important: Raw public key credentials are only supported when serving over HTTP/3. public struct TLSCredentials: Sendable { - enum Backing { - case inMemory(certificateChain: [Certificate], privateKey: Certificate.PrivateKey) - case reloading(certificateReloader: any CertificateReloader) - case pemFile(certificateChainPath: String, privateKeyPath: String) + enum Backing: Sendable { + case x509(X509Credentials) + #if HTTP3 + case rawPublicKey(RawPublicKeyCredentials) + #endif } let backing: Backing - /// Credentials from in-memory certificate objects. + /// X.509 credentials. + /// + /// - Parameter credentials: The X.509 certificate chain and the associated private key. + public static func x509(_ credentials: X509Credentials) -> Self { + Self(backing: .x509(credentials)) + } + + #if HTTP3 + /// Raw public key credentials. + /// + /// - Parameter credentials: The raw public key and the associated private key. + /// + /// - Important: Raw public key credentials are only supported when serving over HTTP/3. + /// + /// - SeeAlso: https://datatracker.ietf.org/doc/html/rfc7250 + public static func rawPublicKey(_ credentials: RawPublicKeyCredentials) -> Self { + Self(backing: .rawPublicKey(credentials)) + } + #endif + } + + /// The encoding format. + enum Encoding: Sendable { + case pem + case der + } + + /// The X.509 credentials the server presents during the TLS handshake. + /// + /// The credentials can be provided in any of the following ways: + /// - As in-memory `X509.Certificate` and `X509.Certificate.PrivateKey` objects (``certificates(chain:privateKey:)``); + /// - From files (``pemFile(certificateChain:privateKey:)``, ``derFile(certificate:privateKey:)``) or bytes + /// (``pemBytes(certificateChain:privateKey:)``, ``derBytes(certificate:privateKey:)``), or; + /// - Through a `CertificateReloader` instance that periodically reloads the credentials (``reloading(_:)``). + public struct X509Credentials: Sendable { + enum SerializedCredentials: Sendable { + case file(certificateChainPath: String, privateKeyPath: String, format: Encoding) + case bytes(certificateChain: [UInt8], privateKey: [UInt8], format: Encoding) + } + + enum Backing: Sendable { + case certificates(chain: [Certificate], privateKey: Certificate.PrivateKey) + case reloading(any CertificateReloader) + case serialized(SerializedCredentials) + } + + let backing: Backing + + /// X.509 credentials provided as in-memory `[X509.Certificate]` and `X509.Certificate.PrivateKey` objects. /// /// - Parameters: - /// - certificateChain: The certificate chain to present during the TLS handshake. - /// - privateKey: The private key corresponding to the leaf certificate in `certificateChain`. - public static func inMemory(certificateChain: [Certificate], privateKey: Certificate.PrivateKey) -> Self { - Self(backing: .inMemory(certificateChain: certificateChain, privateKey: privateKey)) + /// - chain: The certificate chain to present during the TLS handshake. + /// - privateKey: The private key for the leaf certificate in `certificateChain`. + public static func certificates(chain: [Certificate], privateKey: Certificate.PrivateKey) -> Self { + Self(backing: .certificates(chain: chain, privateKey: privateKey)) } - /// Credentials backed by a `CertificateReloader` that periodically refreshes the certificate chain and - /// private key. + /// X.509 credentials provided via a `CertificateReloader` instance. /// - /// - Parameter certificateReloader: The reloader responsible for refreshing the credentials. - public static func reloading(certificateReloader: any CertificateReloader) -> Self { - Self(backing: .reloading(certificateReloader: certificateReloader)) + /// - Parameter reloader: The reloader that supplies and refreshes the credentials. + public static func reloading(_ reloader: any CertificateReloader) -> Self { + Self(backing: .reloading(reloader)) } - /// Credentials loaded from PEM-encoded files on disk. + /// X.509 credentials provided as paths to PEM-encoded files on disk. /// /// - Parameters: - /// - certificateChainPath: The file path to the PEM-encoded certificate chain. - /// - privateKeyPath: The file path to the PEM-encoded private key, corresponding to the leaf certificate in - /// `certificateChainPath`. + /// - certificateChainPath: The path to the PEM-encoded certificate chain. + /// - privateKeyPath: The path to the PEM-encoded private key of the leaf certificate in `certificateChain`. public static func pemFile(certificateChainPath: String, privateKeyPath: String) -> Self { - Self(backing: .pemFile(certificateChainPath: certificateChainPath, privateKeyPath: privateKeyPath)) + Self( + backing: .serialized( + .file(certificateChainPath: certificateChainPath, privateKeyPath: privateKeyPath, format: .pem) + ) + ) + } + + /// X.509 credentials provided as PEM-encoded bytes. + /// + /// - Parameters: + /// - certificateChain: The PEM-encoded bytes representing the certificate chain. + /// - privateKey: The PEM-encoded bytes representing the private key of the leaf certificate in + /// `certificateChain`. + public static func pemBytes(certificateChain: [UInt8], privateKey: [UInt8]) -> Self { + Self(backing: .serialized(.bytes(certificateChain: certificateChain, privateKey: privateKey, format: .pem))) + } + + /// X.509 credentials provided as paths to DER-encoded files on disk. + /// + /// - Parameters: + /// - certificatePath: The path to the DER-encoded certificate. + /// - privateKeyPath: The path to the DER-encoded private key of `certificate`. + public static func derFile(certificatePath: String, privateKeyPath: String) -> Self { + Self( + backing: .serialized( + .file(certificateChainPath: certificatePath, privateKeyPath: privateKeyPath, format: .der) + ) + ) + } + + /// X.509 credentials provided as DER-encoded bytes. + /// + /// - Parameters: + /// - certificate: The DER-encoded bytes representing the certificate. + /// - privateKey: The DER-encoded bytes representing the private key of the `certificate`. + public static func derBytes(certificate: [UInt8], privateKey: [UInt8]) -> Self { + Self(backing: .serialized(.bytes(certificateChain: certificate, privateKey: privateKey, format: .der))) + } + } + + #if HTTP3 + /// The raw public key credentials (RFC 7250) the server presents during the TLS handshake. + /// + /// - Important: Raw public key credentials are currently supported only when the server is configured to serve + /// HTTP/3 exclusively. + /// + /// - SeeAlso: https://datatracker.ietf.org/doc/html/rfc7250 + public struct RawPublicKeyCredentials: Sendable { + enum Backing: Sendable { + case file(publicKeyPath: String, privateKeyPath: String, format: Encoding) + } + + let backing: Backing + + /// Raw public key credentials provided as paths to PEM-encoded files on disk. + /// + /// - Important: PEM-file backed raw public key credentials are not currently supported over HTTP/3; only + /// DER-file credentials are. See ``derFile(publicKey:privateKey:)``. + /// + /// - Parameters: + /// - publicKeyPath: The path to the PEM-encoded public key. + /// - privateKeyPath: The path to the PEM-encoded private key for `publicKey`. + public static func pemFile(publicKeyPath: String, privateKeyPath: String) -> Self { + Self(backing: .file(publicKeyPath: publicKeyPath, privateKeyPath: privateKeyPath, format: .pem)) + } + + /// Raw public key credentials provided as paths to DER-encoded files on disk. + /// + /// - Parameters: + /// - publicKeyPath: The path to the DER-encoded public key. + /// - privateKeyPath: The path to the DER-encoded private key for `publicKey`. + public static func derFile(publicKeyPath: String, privateKeyPath: String) -> Self { + Self(backing: .file(publicKeyPath: publicKeyPath, privateKeyPath: privateKeyPath, format: .der)) } } + #endif // HTTP3 } diff --git a/Sources/NIOHTTPServer/Documentation.docc/SwiftConfigurationIntegration.md b/Sources/NIOHTTPServer/Documentation.docc/SwiftConfigurationIntegration.md index d1d5e87..1265b01 100644 --- a/Sources/NIOHTTPServer/Documentation.docc/SwiftConfigurationIntegration.md +++ b/Sources/NIOHTTPServer/Documentation.docc/SwiftConfigurationIntegration.md @@ -71,11 +71,13 @@ its respective key prefix. | | `topic` | `string` | Optional | nil | | | `description` | `string` | Optional | nil | | `transportSecurity` | `mode` | `string` | Required (permitted values: `"plaintext"`, `"tls"`, `"mTLS"`) | - | -| | `credentialSource` | `string` | Required for `"tls"` and `"mTLS"` (permitted values: `"inline"`, `"file"`) | - | +| | `credentialSource` | `string` | Required for `"tls"` and `"mTLS"` (permitted values: `"inline"`, `"file"`, `"rawPublicKey"`) | - | | | `certificateChainPEMString` | `string` | Required for `credentialSource: "inline"` | - | | | `privateKeyPEMString` | `string` | Required for `credentialSource: "inline"`, secret. | - | | | `certificateChainPEMPath` | `string` | Required for `credentialSource: "file"` | - | | | `privateKeyPEMPath` | `string` | Required for `credentialSource: "file"`, secret. | - | +| | `publicKeyDERPath` | `string` | Required for `credentialSource: "rawPublicKey"` | - | +| | `privateKeyDERPath` | `string` | Required for `credentialSource: "rawPublicKey"`, secret. | - | | | `refreshInterval` | `int` | Optional for `credentialSource: "file"` | - | | | `trustRootsSource` | `string` | Required for `"mTLS"` (permitted values: `"inline"`, `"file"`, `"systemDefaults"`, `"customCertificateVerificationCallback"`) | - | | | `trustRootsPEMString` | `string` | Required for `trustRootsSource: "inline"` | - | @@ -96,6 +98,8 @@ The `credentialSource` determines how server credentials are provided: `certificateChainPEMPath` and `privateKeyPEMPath`. - When `refreshInterval` is provided, credentials are reloaded periodically at the specified interval (in seconds). Otherwise, credentials are loaded from disk once at startup. +- `"rawPublicKey"`: provide file paths to DER-encoded public and private keys, using `publicKeyDERPath` and + `privateKeyDERPath`. Raw public key credentials are only supported when serving over HTTP/3. The `trustRootsSource` determines how mTLS trust roots are provided: - `"inline"`: provide the root certificates as a PEM-encoded string, using `trustRootsPEMString`. diff --git a/Sources/NIOHTTPServer/NIOSSL+X509.swift b/Sources/NIOHTTPServer/NIOSSL+X509.swift index b2dc416..ace4f65 100644 --- a/Sources/NIOHTTPServer/NIOSSL+X509.swift +++ b/Sources/NIOHTTPServer/NIOSSL+X509.swift @@ -50,17 +50,6 @@ extension NIOSSLPrivateKeySource { } } -@available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, visionOS 1.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 10.15, iOS 13, watchOS 6, tvOS 13, visionOS 1.0, *) diff --git a/Sources/RequestHandlerExample/RequestHandlerExample.swift b/Sources/RequestHandlerExample/RequestHandlerExample.swift index 9da11d0..3a62c35 100644 --- a/Sources/RequestHandlerExample/RequestHandlerExample.swift +++ b/Sources/RequestHandlerExample/RequestHandlerExample.swift @@ -41,22 +41,24 @@ struct RequestHandlerExample { bindTarget: .hostAndPort(host: "127.0.0.1", port: 12345), supportedHTTPVersions: [.http1_1, .http2(config: .init())], transportSecurity: .tls( - credentials: .inMemory( - certificateChain: [ - try Certificate( - version: .v3, - serialNumber: .init(bytes: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]), - publicKey: .init(privateKey.publicKey), - notValidBefore: Date.now.addingTimeInterval(-60), - notValidAfter: Date.now.addingTimeInterval(60 * 60), - issuer: DistinguishedName(), - subject: DistinguishedName(), - signatureAlgorithm: .ecdsaWithSHA256, - extensions: .init(), - issuerPrivateKey: Certificate.PrivateKey(privateKey) - ) - ], - privateKey: Certificate.PrivateKey(privateKey) + credentials: .x509( + .certificates( + chain: [ + try Certificate( + version: .v3, + serialNumber: .init(bytes: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]), + publicKey: .init(privateKey.publicKey), + notValidBefore: Date.now.addingTimeInterval(-60), + notValidAfter: Date.now.addingTimeInterval(60 * 60), + issuer: DistinguishedName(), + subject: DistinguishedName(), + signatureAlgorithm: .ecdsaWithSHA256, + extensions: .init(), + issuerPrivateKey: Certificate.PrivateKey(privateKey) + ) + ], + privateKey: Certificate.PrivateKey(privateKey) + ) ) ) ) diff --git a/Tests/NIOHTTPServerTests/HTTP3ConfigurationTests.swift b/Tests/NIOHTTPServerTests/HTTP3ConfigurationTests.swift index 26039d9..0d01747 100644 --- a/Tests/NIOHTTPServerTests/HTTP3ConfigurationTests.swift +++ b/Tests/NIOHTTPServerTests/HTTP3ConfigurationTests.swift @@ -70,8 +70,8 @@ struct HTTP3ConfigurationTests { #expect(throws: NIOHTTPServerConfigurationError.mTLSNotCurrentlySupportedOverHTTP3) { _ = try NIOQUIC.AuthenticationConfiguration( .mTLS( - credentials: .pemFile(certificateChainPath: "/cert.pem", privateKeyPath: "/key.pem"), - trustConfiguration: .pemFile(path: "/roots.pem") + credentials: .x509(.pemFile(certificateChainPath: "/cert.pem", privateKeyPath: "/key.pem")), + trustConfiguration: .init(.pemFile(trustRootsPath: "/roots.pem")) ) ) } @@ -83,7 +83,7 @@ struct HTTP3ConfigurationTests { let chain = try TestCA.makeSelfSignedChain() #expect(throws: NIOHTTPServerConfigurationError.onlyPEMFileCredentialsCurrentlySupportedOverHTTP3) { _ = try NIOQUIC.AuthenticationConfiguration( - .tls(credentials: .inMemory(certificateChain: chain.chain, privateKey: chain.privateKey)) + .tls(credentials: .x509(.certificates(chain: chain.chain, privateKey: chain.privateKey))) ) } } @@ -93,7 +93,7 @@ struct HTTP3ConfigurationTests { func pemFileCredentialsAccepted() { #expect(throws: Never.self) { _ = try NIOQUIC.AuthenticationConfiguration( - .tls(credentials: .pemFile(certificateChainPath: "/cert.pem", privateKeyPath: "/key.pem")) + .tls(credentials: .x509(.pemFile(certificateChainPath: "/cert.pem", privateKeyPath: "/key.pem"))) ) } } diff --git a/Tests/NIOHTTPServerTests/HTTPKeepAliveHandlerTests.swift b/Tests/NIOHTTPServerTests/HTTPKeepAliveHandlerTests.swift index c7d324a..540623f 100644 --- a/Tests/NIOHTTPServerTests/HTTPKeepAliveHandlerTests.swift +++ b/Tests/NIOHTTPServerTests/HTTPKeepAliveHandlerTests.swift @@ -494,10 +494,7 @@ struct HTTPKeepAliveHandlerTests { bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), supportedHTTPVersions: [.http1_1], transportSecurity: .tls( - credentials: .inMemory( - certificateChain: serverChain.chain, - privateKey: serverChain.privateKey - ) + credentials: .x509(.certificates(chain: serverChain.chain, privateKey: serverChain.privateKey)) ) ) ) diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift index 5812317..5f0891b 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift @@ -159,7 +159,7 @@ struct NIOHTTPServiceLifecycleTests { let serverAddress = try await server.listeningAddresses.first! let tlsConfig = try TLSConfiguration.makeTestClientConfiguration( - testTrustRoots: serverChain.chain, + trustRoots: .certificates(serverChain.chain), applicationProtocol: httpVersion.alpnIdentifier ) @@ -299,7 +299,7 @@ struct NIOHTTPServiceLifecycleTests { .http2(config: .init(gracefulShutdown: .init(maximumGracefulShutdownDuration: .milliseconds(500)))), ], transportSecurity: .tls( - credentials: .inMemory(certificateChain: serverChain.chain, privateKey: serverChain.privateKey) + credentials: .x509(.certificates(chain: serverChain.chain, privateKey: serverChain.privateKey)) ) ) ) diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServerEndToEndTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServerEndToEndTests.swift index 18c439d..d05c7d4 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServerEndToEndTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServerEndToEndTests.swift @@ -69,17 +69,14 @@ struct NIOHTTPServerEndToEndTests { func testHTTP2Negotiation() async throws { let serverChain = try TestCA.makeSelfSignedChain() var clientTLSConfig = TLSConfiguration.makeClientConfiguration() - clientTLSConfig.trustRoots = try .init(treatingNilAsSystemTrustRoots: [serverChain.ca]) + clientTLSConfig.trustRoots = try .certificates([serverChain.ca]) clientTLSConfig.certificateVerification = .noHostnameVerification clientTLSConfig.applicationProtocols = ["http/1.1", "h2"] try await TestingChannelSecureUpgradeServer.serve( logger: Logger(label: "NIOHTTPServerEndToEndTests"), transportSecurity: .tls( - credentials: .inMemory( - certificateChain: serverChain.chain, - privateKey: serverChain.privateKey - ) + credentials: .x509(.certificates(chain: serverChain.chain, privateKey: serverChain.privateKey)) ), supportedHTTPVersions: [.http1_1, .http2(config: .defaults)], handler: HTTPServerClosureRequestHandler { request, reqContext, reqReader, resSender in diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServerSwiftConfigurationTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServerSwiftConfigurationTests.swift index 033c441..515c448 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServerSwiftConfigurationTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServerSwiftConfigurationTests.swift @@ -648,7 +648,9 @@ struct NIOHTTPServerSwiftConfigurationTests { return } - guard case .inMemory(let certificateChain, let privateKey) = credentials.backing else { + guard case .x509(let x509) = credentials.backing, + case .certificates(let certificateChain, let privateKey) = x509.backing + else { Issue.record("Expected in-memory TLS credentials, got \(credentials.backing) instead.") return } @@ -679,7 +681,9 @@ struct NIOHTTPServerSwiftConfigurationTests { return } - guard case .reloading = credentials.backing else { + guard case .x509(let x509Credentials) = credentials.backing, + case .reloading = x509Credentials.backing + else { Issue.record("Expected reloading TLS credentials, got \(credentials.backing) instead.") return } @@ -706,12 +710,50 @@ struct NIOHTTPServerSwiftConfigurationTests { return } - guard case .pemFile = credentials.backing else { + guard case .x509(let x509Credentials) = credentials.backing, + case .serialized(.file(_, _, .pem)) = x509Credentials.backing + else { Issue.record("Expected PEM file TLS credentials, got \(credentials.backing) instead.") return } } + #if HTTP3 + @Test("Raw public key credentials") + @available(anyAppleOS 26.0, *) + func testRawPublicKeyCredentials() throws { + let provider = InMemoryProvider( + values: [ + "mode": "tls", + "credentialSource": "rawPublicKey", + "publicKeyDERPath": .init(.string("public.der"), isSecret: false), + "privateKeyDERPath": .init(.string("private.der"), isSecret: false), + ] + ) + let config = ConfigReader(provider: provider) + let snapshot = config.snapshot() + + let transportSecurity = try NIOHTTPServerConfiguration.TransportSecurity(config: snapshot) + + guard case .tls(let credentials) = transportSecurity.backing else { + Issue.record("Expected TLS transport security, got \(transportSecurity.backing) instead.") + return + } + + guard case .rawPublicKey(let rpkCredentials) = credentials.backing else { + Issue.record("Expected raw public key TLS credentials, got \(credentials.backing) instead.") + return + } + + switch rpkCredentials.backing { + case .file(let publicKey, let privateKey, let format): + #expect(format == .der) + #expect(publicKey == "public.der") + #expect(privateKey == "private.der") + } + } + #endif // HTTP3 + @Test("Init fails with missing certificate") @available(anyAppleOS 26.0, *) func testMissingCertificate() throws { @@ -795,7 +837,9 @@ struct NIOHTTPServerSwiftConfigurationTests { return } - guard case .inMemory(let certificateChain, let privateKey) = tlsCredentials.backing else { + guard case .x509(let x509Credentials) = tlsCredentials.backing, + case .certificates(let certificateChain, let privateKey) = x509Credentials.backing + else { Issue.record("Expected in-memory TLS credentials, got \(tlsCredentials.backing) instead.") return } @@ -803,9 +847,9 @@ struct NIOHTTPServerSwiftConfigurationTests { #expect(certificateChain == [serverChain.leaf, serverChain.ca]) #expect(privateKey == serverChain.privateKey) - guard case .customCertificateVerificationCallback = mTLSTrustConfiguration.backing else { + guard case .customCertificateVerificationCallback = mTLSTrustConfiguration.source.backing else { Issue.record( - "Expected a custom verification callback, got \(mTLSTrustConfiguration.backing) instead." + "Expected a custom verification callback, got \(mTLSTrustConfiguration.source.backing) instead." ) return } @@ -840,7 +884,9 @@ struct NIOHTTPServerSwiftConfigurationTests { return } - guard case .inMemory(let certificateChain, let privateKey) = tlsCredentials.backing else { + guard case .x509(let x509Credentials) = tlsCredentials.backing, + case .certificates(let certificateChain, let privateKey) = x509Credentials.backing + else { Issue.record("Expected in-memory TLS credentials, got \(tlsCredentials.backing) instead.") return } @@ -848,8 +894,10 @@ struct NIOHTTPServerSwiftConfigurationTests { #expect(certificateChain == [serverChain.leaf, serverChain.ca]) #expect(privateKey == serverChain.privateKey) - guard case .systemDefaults = mTLSTrustConfiguration.backing else { - Issue.record("Expected system default trust roots, got \(mTLSTrustConfiguration.backing) instead.") + guard case .systemDefaults = mTLSTrustConfiguration.source.backing else { + Issue.record( + "Expected system default trust roots, got \(mTLSTrustConfiguration.source.backing) instead." + ) return } #expect(mTLSTrustConfiguration.certificateVerification.mode == .optionalVerification) @@ -914,7 +962,9 @@ struct NIOHTTPServerSwiftConfigurationTests { return } - guard case .inMemory(let certificateChain, let privateKey) = tlsCredentials.backing else { + guard case .x509(let x509Credentials) = tlsCredentials.backing, + case .certificates(let certificateChain, let privateKey) = x509Credentials.backing + else { Issue.record("Expected in-memory TLS credentials, got \(tlsCredentials.backing) instead.") return } @@ -922,8 +972,10 @@ struct NIOHTTPServerSwiftConfigurationTests { #expect(certificateChain == [serverChain.leaf, serverChain.ca]) #expect(privateKey == serverChain.privateKey) - guard case .systemDefaults = mTLSTrustConfiguration.backing else { - Issue.record("Expected system default trust roots, got \(mTLSTrustConfiguration.backing) instead.") + guard case .systemDefaults = mTLSTrustConfiguration.source.backing else { + Issue.record( + "Expected system default trust roots, got \(mTLSTrustConfiguration.source.backing) instead." + ) return } } @@ -957,9 +1009,9 @@ struct NIOHTTPServerSwiftConfigurationTests { return } - guard case .pemFile(let path) = mTLSTrustConfiguration.backing else { + guard case .serialized(.file(let path, .pem)) = mTLSTrustConfiguration.source.backing else { Issue.record( - "Expected pemFile trust configuration, got \(mTLSTrustConfiguration.backing) instead." + "Expected pemFile trust configuration, got \(mTLSTrustConfiguration.source.backing) instead." ) return } @@ -999,13 +1051,17 @@ struct NIOHTTPServerSwiftConfigurationTests { return } - guard case .reloading = tlsCredentials.backing else { + guard case .x509(let x509Credentials) = tlsCredentials.backing, + case .reloading = x509Credentials.backing + else { Issue.record("Expected reloading TLS credentials, got \(tlsCredentials.backing) instead.") return } - guard case .inMemory(let trustRoots) = mTLSTrustConfiguration.backing else { - Issue.record("Expected in-memory trust roots, got \(mTLSTrustConfiguration.backing) instead.") + guard case .certificates(let trustRoots) = mTLSTrustConfiguration.source.backing else { + Issue.record( + "Expected in-memory trust roots, got \(mTLSTrustConfiguration.source.backing) instead." + ) return } #expect(trustRoots == [chain.ca]) @@ -1069,13 +1125,15 @@ struct NIOHTTPServerSwiftConfigurationTests { return } - guard case .inMemory(let certificateChain, let privateKey) = tlsCredentials.backing else { + guard case .x509(let x509Credentials) = tlsCredentials.backing, + case .certificates(let certificateChain, let privateKey) = x509Credentials.backing + else { Issue.record("Expected in-memory TLS credentials, got \(tlsCredentials.backing) instead.") return } - guard case .inMemory(let trustRoots) = trustConfig.backing else { - Issue.record("Expected in-memory trust roots, got \(trustConfig.backing) instead.") + guard case .certificates(let trustRoots) = trustConfig.source.backing else { + Issue.record("Expected in-memory trust roots, got \(trustConfig.source.backing) instead.") return } diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift index e7509aa..de89986 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift @@ -125,14 +125,18 @@ struct NIOHTTPServerTests { bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), supportedHTTPVersions: [.http1_1, .http2(config: .init())], transportSecurity: .mTLS( - credentials: .inMemory( - certificateChain: [serverChain.leaf], - privateKey: serverChain.privateKey, + credentials: .x509( + .certificates( + chain: [serverChain.leaf], + privateKey: serverChain.privateKey, + ) ), - trustConfiguration: .customCertificateVerificationCallback { certificates in - // Return the peer's certificate chain; this must then be accessible in the request handler - .certificateVerified(.init(.init(uncheckedCertificateChain: certificates))) - } + trustConfiguration: .init( + .customCertificateVerificationCallback { certificates in + // Return the peer's certificate chain; this must then be accessible in the request handler + .certificateVerified(.init(.init(uncheckedCertificateChain: certificates))) + } + ) ) ) ) @@ -860,7 +864,7 @@ extension NIOHTTPServerTests { bindTargets: bindTargets, supportedHTTPVersions: [.http1_1, .http2(config: .defaults)], transportSecurity: .tls( - credentials: .inMemory(certificateChain: serverChain.chain, privateKey: serverChain.privateKey) + credentials: .x509(.certificates(chain: serverChain.chain, privateKey: serverChain.privateKey)) ) ) ) diff --git a/Tests/NIOHTTPServerTests/Utilities/Helpers.swift b/Tests/NIOHTTPServerTests/Utilities/Helpers.swift index 3a33e42..d558378 100644 --- a/Tests/NIOHTTPServerTests/Utilities/Helpers.swift +++ b/Tests/NIOHTTPServerTests/Utilities/Helpers.swift @@ -14,6 +14,10 @@ import NIOCore import NIOEmbedded +import NIOSSL +import X509 + +@testable import NIOHTTPServer extension NIOAsyncTestingChannel { /// Forwards all of our outbound writes to `other` and vice-versa. @@ -66,3 +70,41 @@ extension NIOAsyncTestingChannel { return channel } } + +extension NIOSSLTrustRoots { + static func certificates(_ trustRoots: [Certificate]) throws -> NIOSSLTrustRoots { + .certificates(try trustRoots.map { try NIOSSLCertificate($0) }) + } +} + +extension TLSConfiguration { + /// Creates a client `TLSConfiguration` that trusts `testTrustRoots` and advertises the `applicationProtocol` ALPN + /// identifier. + static func makeTestClientConfiguration( + trustRoots: NIOSSLTrustRoots, + applicationProtocol: String + ) throws -> TLSConfiguration { + var clientTLSConfig = TLSConfiguration.makeClientConfiguration() + clientTLSConfig.trustRoots = trustRoots + clientTLSConfig.certificateVerification = .noHostnameVerification + clientTLSConfig.applicationProtocols = [applicationProtocol] + + return clientTLSConfig + } + + /// Like ``makeTestClientConfiguration``, but with mTLS. + static func makeTestClientMTLSConfiguration( + trustRoots: NIOSSLTrustRoots, + clientCredentials: ChainPrivateKeyPair, + applicationProtocol: String + ) throws -> TLSConfiguration { + var mTLSConfig = try TLSConfiguration.makeTestClientConfiguration( + trustRoots: trustRoots, + applicationProtocol: applicationProtocol + ) + mTLSConfig.certificateChain = [try NIOSSLCertificateSource(clientCredentials.leaf)] + mTLSConfig.privateKey = .privateKey(try .init(clientCredentials.privateKey)) + + return mTLSConfig + } +} diff --git a/Tests/NIOHTTPServerTests/Utilities/NIOClient/NIOClient+SecureUpgrade.swift b/Tests/NIOHTTPServerTests/Utilities/NIOClient/NIOClient+SecureUpgrade.swift index 63c34a2..72518ac 100644 --- a/Tests/NIOHTTPServerTests/Utilities/NIOClient/NIOClient+SecureUpgrade.swift +++ b/Tests/NIOHTTPServerTests/Utilities/NIOClient/NIOClient+SecureUpgrade.swift @@ -84,7 +84,7 @@ extension ClientBootstrap { applicationProtocol: String ) async throws -> NegotiatedClientConnection { let tlsConfig = try TLSConfiguration.makeTestClientConfiguration( - testTrustRoots: trustRoots, + trustRoots: .certificates(try trustRoots.map { try NIOSSLCertificate($0) }), applicationProtocol: applicationProtocol ) @@ -99,29 +99,12 @@ extension ClientBootstrap { trustRoots: [Certificate], applicationProtocol: String ) async throws -> NegotiatedClientConnection { - var mTLSConfig = try TLSConfiguration.makeTestClientConfiguration( - testTrustRoots: trustRoots, + let mTLSConfig = try TLSConfiguration.makeTestClientMTLSConfiguration( + trustRoots: .certificates(try trustRoots.map { try NIOSSLCertificate($0) }), + clientCredentials: clientChain, applicationProtocol: applicationProtocol ) - mTLSConfig.certificateChain = [try NIOSSLCertificateSource(clientChain.leaf)] - mTLSConfig.privateKey = .privateKey(try .init(clientChain.privateKey)) return try await self.connectToTestSecureUpgradeHTTPServer(at: serverAddress, tlsConfig: mTLSConfig) } } - -extension TLSConfiguration { - /// Valid `applicationProtocol` values are `"http/1.1"` (forces HTTP/1.1), `"h2"` (forces HTTP/2), or a - /// comma-separated combination of both in order of preference, e.g. `"http/1.1, h2"`. - static func makeTestClientConfiguration( - testTrustRoots: [Certificate], - applicationProtocol: String - ) throws -> TLSConfiguration { - var clientTLSConfig = TLSConfiguration.makeClientConfiguration() - clientTLSConfig.trustRoots = .certificates(try testTrustRoots.map { try NIOSSLCertificate($0) }) - clientTLSConfig.certificateVerification = .noHostnameVerification - clientTLSConfig.applicationProtocols = [applicationProtocol] - - return clientTLSConfig - } -} From 573c7be14dbf9ccb62da67d57c89095db09ad8f4 Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Wed, 29 Jul 2026 11:27:49 +0100 Subject: [PATCH 2/4] Move swift-configuration integration under SwiftConfiguration directory --- .../HTTP2+SwiftConfiguration.swift | 0 .../HTTP3+SwiftConfiguration.swift | 0 .../NIOHTTPServer+SwiftConfiguration.swift | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename Sources/NIOHTTPServer/Configuration/{SwiftConfig => SwiftConfiguration}/HTTP2+SwiftConfiguration.swift (100%) rename Sources/NIOHTTPServer/Configuration/{SwiftConfig => SwiftConfiguration}/HTTP3+SwiftConfiguration.swift (100%) rename Sources/NIOHTTPServer/Configuration/{ => SwiftConfiguration}/NIOHTTPServer+SwiftConfiguration.swift (100%) diff --git a/Sources/NIOHTTPServer/Configuration/SwiftConfig/HTTP2+SwiftConfiguration.swift b/Sources/NIOHTTPServer/Configuration/SwiftConfiguration/HTTP2+SwiftConfiguration.swift similarity index 100% rename from Sources/NIOHTTPServer/Configuration/SwiftConfig/HTTP2+SwiftConfiguration.swift rename to Sources/NIOHTTPServer/Configuration/SwiftConfiguration/HTTP2+SwiftConfiguration.swift diff --git a/Sources/NIOHTTPServer/Configuration/SwiftConfig/HTTP3+SwiftConfiguration.swift b/Sources/NIOHTTPServer/Configuration/SwiftConfiguration/HTTP3+SwiftConfiguration.swift similarity index 100% rename from Sources/NIOHTTPServer/Configuration/SwiftConfig/HTTP3+SwiftConfiguration.swift rename to Sources/NIOHTTPServer/Configuration/SwiftConfiguration/HTTP3+SwiftConfiguration.swift diff --git a/Sources/NIOHTTPServer/Configuration/NIOHTTPServer+SwiftConfiguration.swift b/Sources/NIOHTTPServer/Configuration/SwiftConfiguration/NIOHTTPServer+SwiftConfiguration.swift similarity index 100% rename from Sources/NIOHTTPServer/Configuration/NIOHTTPServer+SwiftConfiguration.swift rename to Sources/NIOHTTPServer/Configuration/SwiftConfiguration/NIOHTTPServer+SwiftConfiguration.swift From 22e7b101b65111841cf157f31ff0b499f0206520 Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Wed, 29 Jul 2026 11:29:01 +0100 Subject: [PATCH 3/4] Remove pemFile accessor for RPK credentials --- .../TransportSecurity+TLSCredentials.swift | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/Sources/NIOHTTPServer/Configuration/TransportSecurity+TLSCredentials.swift b/Sources/NIOHTTPServer/Configuration/TransportSecurity+TLSCredentials.swift index 2d8b2f7..131a929 100644 --- a/Sources/NIOHTTPServer/Configuration/TransportSecurity+TLSCredentials.swift +++ b/Sources/NIOHTTPServer/Configuration/TransportSecurity+TLSCredentials.swift @@ -154,18 +154,6 @@ extension NIOHTTPServerConfiguration.TransportSecurity { let backing: Backing - /// Raw public key credentials provided as paths to PEM-encoded files on disk. - /// - /// - Important: PEM-file backed raw public key credentials are not currently supported over HTTP/3; only - /// DER-file credentials are. See ``derFile(publicKey:privateKey:)``. - /// - /// - Parameters: - /// - publicKeyPath: The path to the PEM-encoded public key. - /// - privateKeyPath: The path to the PEM-encoded private key for `publicKey`. - public static func pemFile(publicKeyPath: String, privateKeyPath: String) -> Self { - Self(backing: .file(publicKeyPath: publicKeyPath, privateKeyPath: privateKeyPath, format: .pem)) - } - /// Raw public key credentials provided as paths to DER-encoded files on disk. /// /// - Parameters: From 9331af2cacae32c2b5a28746356cf25f4e642a5d Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Thu, 30 Jul 2026 11:15:26 +0100 Subject: [PATCH 4/4] Empty commit to re-trigger CI checks