diff --git a/Sources/NIOSSL/NIOSSLHandler.swift b/Sources/NIOSSL/NIOSSLHandler.swift index eea497c6..33d9c3d9 100644 --- a/Sources/NIOSSL/NIOSSLHandler.swift +++ b/Sources/NIOSSL/NIOSSLHandler.swift @@ -78,7 +78,7 @@ public class NIOSSLHandler: ChannelInboundHandler, ChannelOutboundHandler, Remov let tlsConfiguration = connection.parentContext.configuration precondition( additionalPeerCertificateVerificationCallback == nil || tlsConfiguration.certificateVerification != .none, - "TLSConfiguration.certificateVerification must be either set to .noHostnameVerification or .fullVerification if additionalPeerCertificateVerificationCallback is specified" + "TLSConfiguration.certificateVerification must be either set to .optionalVerification, .noHostnameVerification, or .fullVerification if additionalPeerCertificateVerificationCallback is specified" ) self.connection = connection // 96 brings the total size of the buffer to just shy of one page diff --git a/Sources/NIOSSL/SSLContext.swift b/Sources/NIOSSL/SSLContext.swift index 64259ad1..8aa929c8 100644 --- a/Sources/NIOSSL/SSLContext.swift +++ b/Sources/NIOSSL/SSLContext.swift @@ -600,6 +600,51 @@ extension NIOSSLContext { // Configuring certificate verification extension NIOSSLContext { + fileprivate enum VerificationMode { + case peerCertificateRequired + case peerCertificatesOptional + } + + fileprivate static func setupVerification( + _ context: OpaquePointer, + _ sendCANames: Bool, + _ trustRoots: NIOSSLTrustRoots?, + _ additionalTrustRoots: [NIOSSLAdditionalTrustRoots], + _ verificationMode: VerificationMode + ) throws { + switch verificationMode { + case .peerCertificateRequired: + CNIOBoringSSL_SSL_CTX_set_verify(context, SSL_VERIFY_PEER | SSL_VERIFY_FAIL_IF_NO_PEER_CERT, nil) + case .peerCertificatesOptional: + CNIOBoringSSL_SSL_CTX_set_verify(context, SSL_VERIFY_PEER, nil) + } + + // Also, set TRUSTED_FIRST to work around dumb clients that don't know what they're doing and send + // untrusted root certs. X509_VERIFY_PARAM will or-in the flags, so we don't need to load them first. + // This is get0 so we can just ignore the pointer, we don't have an owned ref. + let trustParams = CNIOBoringSSL_SSL_CTX_get0_param(context)! + CNIOBoringSSL_X509_VERIFY_PARAM_set_flags(trustParams, CUnsignedLong(X509_V_FLAG_TRUSTED_FIRST)) + + func configureTrustRoots(trustRoots: NIOSSLTrustRoots) throws { + switch trustRoots { + case .default: + try NIOSSLContext.platformDefaultConfiguration(context: context) + case .file(let path): + try NIOSSLContext.loadVerifyLocations(path, context: context, sendCANames: sendCANames) + case .certificates(let certs): + for cert in certs { + try NIOSSLContext.addRootCertificate(cert, context: context) + // Add the CA name from the trust root + if sendCANames { + try NIOSSLContext.addCACertificateNameToList(context: context, certificate: cert) + } + } + } + } + try configureTrustRoots(trustRoots: trustRoots ?? .default) + for root in additionalTrustRoots { try configureTrustRoots(trustRoots: .init(from: root)) } + } + private static func configureCertificateValidation( context: OpaquePointer, verification: CertificateVerification, @@ -610,34 +655,11 @@ extension NIOSSLContext { // If validation is turned on, set the trust roots and turn on cert validation. switch verification { case .fullVerification, .noHostnameVerification: - CNIOBoringSSL_SSL_CTX_set_verify(context, SSL_VERIFY_PEER | SSL_VERIFY_FAIL_IF_NO_PEER_CERT, nil) - - // Also, set TRUSTED_FIRST to work around dumb clients that don't know what they're doing and send - // untrusted root certs. X509_VERIFY_PARAM will or-in the flags, so we don't need to load them first. - // This is get0 so we can just ignore the pointer, we don't have an owned ref. - let trustParams = CNIOBoringSSL_SSL_CTX_get0_param(context)! - CNIOBoringSSL_X509_VERIFY_PARAM_set_flags(trustParams, CUnsignedLong(X509_V_FLAG_TRUSTED_FIRST)) - - func configureTrustRoots(trustRoots: NIOSSLTrustRoots) throws { - switch trustRoots { - case .default: - try NIOSSLContext.platformDefaultConfiguration(context: context) - case .file(let path): - try NIOSSLContext.loadVerifyLocations(path, context: context, sendCANames: sendCANames) - case .certificates(let certs): - for cert in certs { - try NIOSSLContext.addRootCertificate(cert, context: context) - // Add the CA name from the trust root - if sendCANames { - try NIOSSLContext.addCACertificateNameToList(context: context, certificate: cert) - } - } - } + try setupVerification(context, sendCANames, trustRoots, additionalTrustRoots, .peerCertificateRequired) + case .none(let opts): + if opts.validatePresentedCertificates { + try setupVerification(context, sendCANames, trustRoots, additionalTrustRoots, .peerCertificatesOptional) } - try configureTrustRoots(trustRoots: trustRoots ?? .default) - for root in additionalTrustRoots { try configureTrustRoots(trustRoots: .init(from: root)) } - default: - break } } diff --git a/Sources/NIOSSL/TLSConfiguration.swift b/Sources/NIOSSL/TLSConfiguration.swift index c0553f70..6f52486f 100644 --- a/Sources/NIOSSL/TLSConfiguration.swift +++ b/Sources/NIOSSL/TLSConfiguration.swift @@ -169,8 +169,19 @@ public enum NIOSSLSerializationFormats: Sendable { /// Certificate verification modes. public enum CertificateVerification: Sendable { - /// All certificate verification disabled. - case none + public struct NoneOptions: Sendable, Equatable, Hashable { + /// While the peer does not have to give you certificates, + /// they can optionally be verified if the peer offers them. + public var validatePresentedCertificates: Bool + + fileprivate init() { + // Backwards-compatible + self.validatePresentedCertificates = false + } + } + + /// Usable through ``none`` and ``optionalVerification``. + case none(NoneOptions) /// Certificates will be validated against the trust store, but will not /// be checked to see if they are valid for the given hostname. @@ -181,6 +192,26 @@ public enum CertificateVerification: Sendable { case fullVerification } +extension CertificateVerification { + /// Certificates will be validated if they are presented by the peer, i.e., if the peer presents + /// certificates they must pass validation. However, if the peer does not present certificates, + /// the connection will be accepted. + public static var optionalVerification: CertificateVerification { + var options = NoneOptions() + options.validatePresentedCertificates = true + return .none(options) + } + + /// All certificate verification disabled. + public static var none: CertificateVerification { + .none(NoneOptions()) + } +} + +extension CertificateVerification: Hashable { + // empty +} + /// Support for TLS renegotiation. /// /// In general, renegotiation should not be enabled except in circumstances where it is absolutely necessary. diff --git a/Tests/NIOSSLTests/NIOSSLIntegrationTest.swift b/Tests/NIOSSLTests/NIOSSLIntegrationTest.swift index c518fed9..62fb5fb4 100644 --- a/Tests/NIOSSLTests/NIOSSLIntegrationTest.swift +++ b/Tests/NIOSSLTests/NIOSSLIntegrationTest.swift @@ -2600,6 +2600,170 @@ class NIOSSLIntegrationTest: XCTestCase { XCTAssertNoThrow(try handshakeCompletePromise.futureResult.wait()) } + func testMacOSConnectionFailsIfServerVerificationOptionalAndPeerPresentsUntrustedCert() throws { + // This test checks that when setting verification to `.optionalVerification`, a peer cannot successfully + // connect when they present an untrusted certificate. On macOS, this exercises the SecTrust validation backend, + // as `serverConfig.trustRoots` is set to `.default` (see the behavioral matrix in + // `NIOSSL/Docs/trust-roots-behavior.md`). + var serverConfig = TLSConfiguration.makeServerConfiguration( + certificateChain: [.certificate(NIOSSLIntegrationTest.cert)], + privateKey: .privateKey(NIOSSLIntegrationTest.key) + ) + serverConfig.certificateVerification = .optionalVerification + serverConfig.trustRoots = .default + + var clientConfig = TLSConfiguration.makeClientConfiguration() + clientConfig.certificateVerification = .noHostnameVerification + clientConfig.trustRoots = .default + clientConfig.additionalTrustRoots = [.certificates([NIOSSLIntegrationTest.cert])] + // The client presents a random cert but the server won't trust it + let clientCertAndPrivateKey = generateSelfSignedCert() + clientConfig.certificateChain = [.certificate(clientCertAndPrivateKey.0)] + clientConfig.privateKey = .privateKey(clientCertAndPrivateKey.1) + + let serverContext = try assertNoThrowWithValue(NIOSSLContext(configuration: serverConfig)) + let clientContext = try assertNoThrowWithValue(NIOSSLContext(configuration: clientConfig)) + + let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) + defer { + XCTAssertNoThrow(try group.syncShutdownGracefully()) + } + + let handshakeCompletePromise = group.next().makePromise(of: Void.self) + let serverChannel: Channel = try serverTLSChannel( + context: serverContext, + handlers: [WaitForHandshakeHandler(handshakeResultPromise: handshakeCompletePromise)], + group: group + ) + defer { + XCTAssertNoThrow(try serverChannel.close().wait()) + } + + let clientChannel = try clientTLSChannel( + context: clientContext, + preHandlers: [], + postHandlers: [], + group: group, + connectingTo: serverChannel.localAddress!, + serverHostname: "localhost" + ) + defer { + XCTAssertNoThrow(try clientChannel.close().wait()) + } + + // The handshake should fail: certificate verification is optional and the client hasn't presented any certs. + XCTAssertThrowsError(try handshakeCompletePromise.futureResult.wait()) + } + + func testMacOSConnectionSuccessfulIfServerVerificationOptionalAndPeerPresentsTrustedCert() throws { + // This test checks that when setting verification to `.optionalVerification`, a peer can successfully + // connect when they present a trusted certificate. On macOS, this exercises the SecTrust validation backend, + // as `serverConfig.trustRoots` is set to `.default` and the client cert is registered under + // `additionalTrustRoots` (see the behavioral matrix in `NIOSSL/Docs.docc/trust-roots-behavior.md`). + var clientConfig = TLSConfiguration.makeClientConfiguration() + clientConfig.certificateVerification = .noHostnameVerification + clientConfig.trustRoots = .default + clientConfig.additionalTrustRoots = [.certificates([NIOSSLIntegrationTest.cert])] + // The client presents a generated cert + let clientCertAndPrivateKey = generateSelfSignedCert() + clientConfig.certificateChain = [.certificate(clientCertAndPrivateKey.0)] + clientConfig.privateKey = .privateKey(clientCertAndPrivateKey.1) + + var serverConfig = TLSConfiguration.makeServerConfiguration( + certificateChain: [.certificate(NIOSSLIntegrationTest.cert)], + privateKey: .privateKey(NIOSSLIntegrationTest.key) + ) + serverConfig.certificateVerification = .optionalVerification + serverConfig.trustRoots = .default + // The server trusts the client's generated cert + serverConfig.additionalTrustRoots = [.certificates([clientCertAndPrivateKey.0])] + + let serverContext = try assertNoThrowWithValue(NIOSSLContext(configuration: serverConfig)) + let clientContext = try assertNoThrowWithValue(NIOSSLContext(configuration: clientConfig)) + + let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) + defer { + XCTAssertNoThrow(try group.syncShutdownGracefully()) + } + + let handshakeCompletePromise = group.next().makePromise(of: Void.self) + let serverChannel: Channel = try serverTLSChannel( + context: serverContext, + handlers: [WaitForHandshakeHandler(handshakeResultPromise: handshakeCompletePromise)], + group: group + ) + defer { + XCTAssertNoThrow(try serverChannel.close().wait()) + } + + let clientChannel = try clientTLSChannel( + context: clientContext, + preHandlers: [], + postHandlers: [], + group: group, + connectingTo: serverChannel.localAddress!, + serverHostname: "localhost" + ) + defer { + XCTAssertNoThrow(try clientChannel.close().wait()) + } + + // The handshake should succeed: verification is optional, and the client presents a cert the server trusts. + XCTAssertNoThrow(try handshakeCompletePromise.futureResult.wait()) + } + + func testMacOSConnectionSuccessfulIfServerVerificationOptionalAndNoPeerCert() throws { + // This test checks that when setting verification to `.optionalVerification`, a peer can successfully connect + // when they don't present any certificate. On macOS, this exercises the SecTrust validation backend, as + // `serverConfig.trustRoots` is set to `.default` (see the behavioral matrix in + // `NIOSSL/Docs.docc/trust-roots-behavior.md`). + var serverConfig = TLSConfiguration.makeServerConfiguration( + certificateChain: [.certificate(NIOSSLIntegrationTest.cert)], + privateKey: .privateKey(NIOSSLIntegrationTest.key) + ) + serverConfig.certificateVerification = .optionalVerification + serverConfig.trustRoots = .default + + // The client doesn't present any certs + var clientConfig = TLSConfiguration.makeClientConfiguration() + clientConfig.certificateVerification = .noHostnameVerification + clientConfig.trustRoots = .default + clientConfig.additionalTrustRoots = [.certificates([NIOSSLIntegrationTest.cert])] + + let serverContext = try assertNoThrowWithValue(NIOSSLContext(configuration: serverConfig)) + let clientContext = try assertNoThrowWithValue(NIOSSLContext(configuration: clientConfig)) + + let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) + defer { + XCTAssertNoThrow(try group.syncShutdownGracefully()) + } + + let handshakeCompletePromise = group.next().makePromise(of: Void.self) + let serverChannel: Channel = try serverTLSChannel( + context: serverContext, + handlers: [WaitForHandshakeHandler(handshakeResultPromise: handshakeCompletePromise)], + group: group + ) + defer { + XCTAssertNoThrow(try serverChannel.close().wait()) + } + + let clientChannel = try clientTLSChannel( + context: clientContext, + preHandlers: [], + postHandlers: [], + group: group, + connectingTo: serverChannel.localAddress!, + serverHostname: "localhost" + ) + defer { + XCTAssertNoThrow(try clientChannel.close().wait()) + } + + // The handshake should succeed: certificate verification is optional and the client hasn't presented any certs. + XCTAssertNoThrow(try handshakeCompletePromise.futureResult.wait()) + } + func testServerHasNewCallbackCalledToo() throws { var config = TLSConfiguration.makeServerConfiguration( certificateChain: [.certificate(NIOSSLIntegrationTest.cert)], diff --git a/Tests/NIOSSLTests/TLSConfigurationTest.swift b/Tests/NIOSSLTests/TLSConfigurationTest.swift index 65bf657a..9e83076c 100644 --- a/Tests/NIOSSLTests/TLSConfigurationTest.swift +++ b/Tests/NIOSSLTests/TLSConfigurationTest.swift @@ -552,6 +552,69 @@ class TLSConfigurationTest: XCTestCase { ) } + func testMutualValidationWithCertVerificationOptionalSuccess_NoPeerCert() throws { + // The client doesn't present a cert chain + var clientConfig = TLSConfiguration.makeClientConfiguration() + clientConfig.certificateVerification = .noHostnameVerification + clientConfig.trustRoots = .default + clientConfig.additionalTrustRoots = [.certificates([TLSConfigurationTest.cert1])] + + var serverConfig = TLSConfiguration.makeServerConfiguration( + certificateChain: [.certificate(TLSConfigurationTest.cert1)], + privateKey: .privateKey(TLSConfigurationTest.key1) + ) + // The server sets `certificateVerification` to `optionalVerification`; handshake should succeed when the client + // hasn't presented any certs + serverConfig.certificateVerification = .optionalVerification + serverConfig.trustRoots = .default + + try assertHandshakeSucceeded(withClientConfig: clientConfig, andServerConfig: serverConfig) + } + + func testMutualValidationWithCertVerificationOptionalError_PeerCertNotTrusted() throws { + var clientConfig = TLSConfiguration.makeClientConfiguration() + clientConfig.certificateChain = [.certificate(TLSConfigurationTest.cert2)] + clientConfig.privateKey = .privateKey(TLSConfigurationTest.key2) + clientConfig.certificateVerification = .noHostnameVerification + clientConfig.trustRoots = .default + clientConfig.additionalTrustRoots = [.certificates([TLSConfigurationTest.cert1])] + + var serverConfig = TLSConfiguration.makeServerConfiguration( + certificateChain: [.certificate(TLSConfigurationTest.cert1)], + privateKey: .privateKey(TLSConfigurationTest.key1) + ) + serverConfig.certificateVerification = .optionalVerification + serverConfig.trustRoots = .default + // The server doesn't trust any additional roots; the cert presented by the client will not be trusted + serverConfig.additionalTrustRoots = [] + + try assertPostHandshakeError( + withClientConfig: clientConfig, + andServerConfig: serverConfig, + errorTextContainsAnyOf: ["SSLV3_ALERT_CERTIFICATE_UNKNOWN", "TLSV1_ALERT_UNKNOWN_CA"] + ) + } + + func testMutualValidationWithCertVerificationOptionalSuccess_PeerCertTrusted() throws { + var clientConfig = TLSConfiguration.makeClientConfiguration() + clientConfig.certificateChain = [.certificate(TLSConfigurationTest.cert2)] + clientConfig.privateKey = .privateKey(TLSConfigurationTest.key2) + clientConfig.certificateVerification = .noHostnameVerification + clientConfig.trustRoots = .default + clientConfig.additionalTrustRoots = [.certificates([TLSConfigurationTest.cert1])] + + var serverConfig = TLSConfiguration.makeServerConfiguration( + certificateChain: [.certificate(TLSConfigurationTest.cert1)], + privateKey: .privateKey(TLSConfigurationTest.key1) + ) + serverConfig.certificateVerification = .optionalVerification + serverConfig.trustRoots = .default + // The server trusts the cert presented by the client; we expect a successful handshake + serverConfig.additionalTrustRoots = [.certificates([TLSConfigurationTest.cert2])] + + try assertHandshakeSucceeded(withClientConfig: clientConfig, andServerConfig: serverConfig) + } + func testMutualValidationRequiresClientCertificatePreTLS13() throws { var clientConfig = TLSConfiguration.makeClientConfiguration() clientConfig.maximumTLSVersion = .tlsv12