diff --git a/Package.swift b/Package.swift index 145e2c0..e22ecd2 100644 --- a/Package.swift +++ b/Package.swift @@ -57,6 +57,10 @@ let dependencies: [Package.Dependency] = [ url: "https://github.com/apple/swift-nio-extras.git", from: "1.4.0" ), + .package( + url: "https://github.com/apple/swift-certificates.git", + from: "1.5.0" + ), ] let defaultSwiftSettings: [SwiftSetting] = [ @@ -104,6 +108,7 @@ let targets: [Target] = [ .target(name: "GRPCNIOTransportCore"), .product(name: "GRPCCore", package: "grpc-swift"), .product(name: "NIOPosix", package: "swift-nio"), + .product(name: "NIOSSL", package: "swift-nio-ssl"), ], swiftSettings: defaultSwiftSettings ), @@ -132,6 +137,8 @@ let targets: [Target] = [ name: "GRPCNIOTransportHTTP2Tests", dependencies: [ .target(name: "GRPCNIOTransportHTTP2"), + .product(name: "X509", package: "swift-certificates"), + .product(name: "NIOSSL", package: "swift-nio-ssl"), ] ) ] diff --git a/Sources/GRPCNIOTransportHTTP2Posix/TLSConfig.swift b/Sources/GRPCNIOTransportHTTP2Posix/TLSConfig.swift index 0defef5..1cbf291 100644 --- a/Sources/GRPCNIOTransportHTTP2Posix/TLSConfig.swift +++ b/Sources/GRPCNIOTransportHTTP2Posix/TLSConfig.swift @@ -172,6 +172,27 @@ extension HTTP2ServerTransport.Posix.Config { /// If this is set to `true` but the client does not support ALPN, then the connection will be rejected. public var requireALPN: Bool + /// Create a new HTTP2 NIO Posix server transport TLS config. + /// - Parameters: + /// - certificateChain: The certificates the server will offer during negotiation. + /// - privateKey: The private key associated with the leaf certificate. + /// - clientCertificateVerification: How to verify the client certificate, if one is presented. + /// - trustRoots: The trust roots to be used when verifying client certificates. + /// - requireALPN: Whether ALPN is required. + public init( + certificateChain: [TLSConfig.CertificateSource], + privateKey: TLSConfig.PrivateKeySource, + clientCertificateVerification: TLSConfig.CertificateVerification, + trustRoots: TLSConfig.TrustRootsSource, + requireALPN: Bool + ) { + self.certificateChain = certificateChain + self.privateKey = privateKey + self.clientCertificateVerification = clientCertificateVerification + self.trustRoots = trustRoots + self.requireALPN = requireALPN + } + /// Create a new HTTP2 NIO Posix transport TLS config, with some values defaulted: /// - `clientCertificateVerificationMode` equals `doNotVerify` /// - `trustRoots` equals `systemDefault` @@ -180,18 +201,22 @@ extension HTTP2ServerTransport.Posix.Config { /// - Parameters: /// - certificateChain: The certificates the server will offer during negotiation. /// - privateKey: The private key associated with the leaf certificate. + /// - configure: A closure which allows you to modify the defaults before returning them. /// - Returns: A new HTTP2 NIO Posix transport TLS config. public static func defaults( certificateChain: [TLSConfig.CertificateSource], - privateKey: TLSConfig.PrivateKeySource + privateKey: TLSConfig.PrivateKeySource, + configure: (_ config: inout Self) -> Void = { _ in } ) -> Self { - Self( + var config = Self( certificateChain: certificateChain, privateKey: privateKey, clientCertificateVerification: .noVerification, trustRoots: .systemDefault, requireALPN: false ) + configure(&config) + return config } /// Create a new HTTP2 NIO Posix transport TLS config, with some values defaulted to match @@ -203,18 +228,22 @@ extension HTTP2ServerTransport.Posix.Config { /// - Parameters: /// - certificateChain: The certificates the server will offer during negotiation. /// - privateKey: The private key associated with the leaf certificate. + /// - configure: A closure which allows you to modify the defaults before returning them. /// - Returns: A new HTTP2 NIO Posix transport TLS config. public static func mTLS( certificateChain: [TLSConfig.CertificateSource], - privateKey: TLSConfig.PrivateKeySource + privateKey: TLSConfig.PrivateKeySource, + configure: (_ config: inout Self) -> Void = { _ in } ) -> Self { - Self( + var config = Self( certificateChain: certificateChain, privateKey: privateKey, clientCertificateVerification: .noHostnameVerification, trustRoots: .systemDefault, requireALPN: false ) + configure(&config) + return config } } } @@ -256,6 +285,27 @@ extension HTTP2ClientTransport.Posix.Config { /// An optional server hostname to use when verifying certificates. public var serverHostname: String? + /// Create a new HTTP2 NIO Posix client transport TLS config. + /// - Parameters: + /// - certificateChain: The certificates the client will offer during negotiation. + /// - privateKey: The private key associated with the leaf certificate. + /// - serverCertificateVerification: How to verify the server certificate, if one is presented. + /// - trustRoots: The trust roots to be used when verifying server certificates. + /// - serverHostname: An optional server hostname to use when verifying certificates. + public init( + certificateChain: [TLSConfig.CertificateSource], + privateKey: TLSConfig.PrivateKeySource?, + serverCertificateVerification: TLSConfig.CertificateVerification, + trustRoots: TLSConfig.TrustRootsSource, + serverHostname: String? + ) { + self.certificateChain = certificateChain + self.privateKey = privateKey + self.serverCertificateVerification = serverCertificateVerification + self.trustRoots = trustRoots + self.serverHostname = serverHostname + } + /// Create a new HTTP2 NIO Posix transport TLS config, with some values defaulted: /// - `certificateChain` equals `[]` /// - `privateKey` equals `nil` @@ -263,35 +313,57 @@ extension HTTP2ClientTransport.Posix.Config { /// - `trustRoots` equals `systemDefault` /// - `serverHostname` equals `nil` /// + /// - Parameters: + /// - configure: A closure which allows you to modify the defaults before returning them. /// - Returns: A new HTTP2 NIO Posix transport TLS config. - public static var defaults: Self { - Self( + public static func defaults( + configure: (_ config: inout Self) -> Void = { _ in } + ) -> Self { + var config = Self( certificateChain: [], privateKey: nil, serverCertificateVerification: .fullVerification, trustRoots: .systemDefault, serverHostname: nil ) + configure(&config) + return config + } + + /// Create a new HTTP2 NIO Posix transport TLS config, with some values defaulted: + /// - `certificateChain` equals `[]` + /// - `privateKey` equals `nil` + /// - `serverCertificateVerification` equals `fullVerification` + /// - `trustRoots` equals `systemDefault` + /// - `serverHostname` equals `nil` + public static var defaults: Self { + Self.defaults() } /// Create a new HTTP2 NIO Posix transport TLS config, with some values defaulted to match /// the requirements of mTLS: /// - `trustRoots` equals `systemDefault` + /// - `serverCertificateVerification` equals `fullVerification` /// /// - Parameters: /// - certificateChain: The certificates the client will offer during negotiation. /// - privateKey: The private key associated with the leaf certificate. + /// - configure: A closure which allows you to modify the defaults before returning them. /// - Returns: A new HTTP2 NIO Posix transport TLS config. public static func mTLS( certificateChain: [TLSConfig.CertificateSource], - privateKey: TLSConfig.PrivateKeySource + privateKey: TLSConfig.PrivateKeySource, + configure: (_ config: inout Self) -> Void = { _ in } ) -> Self { - Self( + var config = Self( certificateChain: certificateChain, privateKey: privateKey, serverCertificateVerification: .fullVerification, - trustRoots: .systemDefault + trustRoots: .systemDefault, + serverHostname: nil ) + configure(&config) + return config } } } diff --git a/Sources/GRPCNIOTransportHTTP2TransportServices/TLSConfig.swift b/Sources/GRPCNIOTransportHTTP2TransportServices/TLSConfig.swift index baa8b20..1784748 100644 --- a/Sources/GRPCNIOTransportHTTP2TransportServices/TLSConfig.swift +++ b/Sources/GRPCNIOTransportHTTP2TransportServices/TLSConfig.swift @@ -45,6 +45,18 @@ extension HTTP2ServerTransport.TransportServices.Config { /// If this is set to `true` but the client does not support ALPN, then the connection will be rejected. public var requireALPN: Bool + /// Create a new HTTP2 NIO Transport Services transport TLS config. + /// - Parameters: + /// - requireALPN: Whether ALPN is required. + /// - identityProvider: A provider for the `SecIdentity` to be used when setting up TLS. + public init( + requireALPN: Bool, + identityProvider: @Sendable @escaping () throws -> SecIdentity + ) { + self.requireALPN = requireALPN + self.identityProvider = identityProvider + } + /// Create a new HTTP2 NIO Transport Services transport TLS config, with some values defaulted: /// - `requireALPN` equals `false` /// @@ -52,10 +64,7 @@ extension HTTP2ServerTransport.TransportServices.Config { public static func defaults( identityProvider: @Sendable @escaping () throws -> SecIdentity ) -> Self { - Self( - identityProvider: identityProvider, - requireALPN: false - ) + Self(requireALPN: false, identityProvider: identityProvider) } } } diff --git a/Tests/GRPCNIOTransportHTTP2Tests/HTTP2TransportTLSEnabledTests.swift b/Tests/GRPCNIOTransportHTTP2Tests/HTTP2TransportTLSEnabledTests.swift new file mode 100644 index 0000000..7992826 --- /dev/null +++ b/Tests/GRPCNIOTransportHTTP2Tests/HTTP2TransportTLSEnabledTests.swift @@ -0,0 +1,432 @@ +/* + * Copyright 2024, gRPC Authors All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Crypto +import Foundation +import GRPCNIOTransportHTTP2Posix +import NIOSSL +import SwiftASN1 +import Testing +import X509 + +@Suite("HTTP/2 transport E2E tests with TLS enabled") +struct HTTP2TransportTLSEnabledTests { + // - MARK: Tests + + @Test( + "When using defaults, server does not perform client verification", + arguments: [TransportKind.posix], + [TransportKind.posix] + ) + func testRPC_Defaults_OK( + clientTransport: TransportKind, + serverTransport: TransportKind + ) async throws { + let certificateKeyPairs = try SelfSignedCertificateKeyPairs() + let clientTransportConfig = self.makeDefaultClientTLSConfig( + for: clientTransport, + certificateKeyPairs: certificateKeyPairs + ) + let serverTransportConfig = self.makeDefaultServerTLSConfig( + for: serverTransport, + certificateKeyPairs: certificateKeyPairs + ) + + try await self.withClientAndServer( + clientTLSConfig: clientTransportConfig, + serverTLSConfig: serverTransportConfig + ) { control in + await #expect(throws: Never.self) { + try await self.executeUnaryRPC(control: control) + } + } + } + + @Test( + "When using mTLS defaults, both client and server verify each others' certificates", + arguments: [TransportKind.posix], + [TransportKind.posix] + ) + func testRPC_mTLS_OK( + clientTransport: TransportKind, + serverTransport: TransportKind + ) async throws { + let certificateKeyPairs = try SelfSignedCertificateKeyPairs() + let clientTransportConfig = self.makeMTLSClientTLSConfig( + for: clientTransport, + certificateKeyPairs: certificateKeyPairs, + serverHostname: "localhost" + ) + let serverTransportConfig = self.makeMTLSServerTLSConfig( + for: serverTransport, + certificateKeyPairs: certificateKeyPairs, + includeClientCertificateInTrustRoots: true + ) + + try await self.withClientAndServer( + clientTLSConfig: clientTransportConfig, + serverTLSConfig: serverTransportConfig + ) { control in + await #expect(throws: Never.self) { + try await self.executeUnaryRPC(control: control) + } + } + } + + @Test( + "Error is surfaced when client fails server verification", + arguments: [TransportKind.posix], + [TransportKind.posix] + ) + // Verification should fail because the custom hostname is missing on the client. + func testClientFailsServerValidation( + clientTransport: TransportKind, + serverTransport: TransportKind + ) async throws { + let certificateKeyPairs = try SelfSignedCertificateKeyPairs() + let clientTransportConfig = self.makeMTLSClientTLSConfig( + for: clientTransport, + certificateKeyPairs: certificateKeyPairs, + serverHostname: nil + ) + let serverTransportConfig = self.makeMTLSServerTLSConfig( + for: serverTransport, + certificateKeyPairs: certificateKeyPairs, + includeClientCertificateInTrustRoots: true + ) + + try await self.withClientAndServer( + clientTLSConfig: clientTransportConfig, + serverTLSConfig: serverTransportConfig + ) { control in + await #expect { + try await self.executeUnaryRPC(control: control) + } throws: { error in + guard let rootError = error as? RPCError else { + Issue.record("Should be an RPC error") + return false + } + #expect(rootError.code == .unavailable) + #expect( + rootError.message + == "The server accepted the TCP connection but closed the connection before completing the HTTP/2 connection preface." + ) + + guard + let sslError = rootError.cause as? NIOSSLExtraError, + case .failedToValidateHostname = sslError + else { + Issue.record( + "Should be a NIOSSLExtraError.failedToValidateHostname error, but was: \(String(describing: rootError.cause))" + ) + return false + } + + return true + } + } + } + + @Test( + "Error is surfaced when server fails client verification", + arguments: [TransportKind.posix], + [TransportKind.posix] + ) + // Verification should fail because the server does not have trust roots containing the client cert. + func testServerFailsClientValidation( + clientTransport: TransportKind, + serverTransport: TransportKind + ) async throws { + let certificateKeyPairs = try SelfSignedCertificateKeyPairs() + let clientTransportConfig = self.makeMTLSClientTLSConfig( + for: clientTransport, + certificateKeyPairs: certificateKeyPairs, + serverHostname: "localhost" + ) + let serverTransportConfig = self.makeMTLSServerTLSConfig( + for: serverTransport, + certificateKeyPairs: certificateKeyPairs, + includeClientCertificateInTrustRoots: false + ) + + try await self.withClientAndServer( + clientTLSConfig: clientTransportConfig, + serverTLSConfig: serverTransportConfig + ) { control in + await #expect { + try await self.executeUnaryRPC(control: control) + } throws: { error in + guard let rootError = error as? RPCError else { + Issue.record("Should be an RPC error") + return false + } + #expect(rootError.code == .unavailable) + #expect( + rootError.message + == "The server accepted the TCP connection but closed the connection before completing the HTTP/2 connection preface." + ) + + guard + let sslError = rootError.cause as? NIOSSL.BoringSSLError, + case .sslError = sslError + else { + Issue.record( + "Should be a NIOSSL.sslError error, but was: \(String(describing: rootError.cause))" + ) + return false + } + + return true + } + } + } + + // - MARK: Test Utilities + + enum TransportKind: Sendable { + case posix + } + + enum TLSConfig { + enum Client { + case posix(HTTP2ClientTransport.Posix.Config.TransportSecurity) + } + + enum Server { + case posix(HTTP2ServerTransport.Posix.Config.TransportSecurity) + } + } + + func makeDefaultClientTLSConfig( + for transportSecurity: TransportKind, + certificateKeyPairs: SelfSignedCertificateKeyPairs + ) -> TLSConfig.Client { + switch transportSecurity { + case .posix: + return .posix( + .tls( + .defaults { + $0.trustRoots = .certificates([ + .bytes(certificateKeyPairs.server.certificate, format: .der) + ]) + $0.serverHostname = "localhost" + } + ) + ) + } + } + + func makeMTLSClientTLSConfig( + for transportKind: TransportKind, + certificateKeyPairs: SelfSignedCertificateKeyPairs, + serverHostname: String? + ) -> TLSConfig.Client { + switch transportKind { + case .posix: + return .posix( + .tls( + .mTLS( + certificateChain: [.bytes(certificateKeyPairs.client.certificate, format: .der)], + privateKey: .bytes(certificateKeyPairs.client.key, format: .der) + ) { + $0.trustRoots = .certificates([ + .bytes(certificateKeyPairs.server.certificate, format: .der) + ]) + $0.serverHostname = serverHostname + } + ) + ) + } + } + + func makeDefaultServerTLSConfig( + for transportKind: TransportKind, + certificateKeyPairs: SelfSignedCertificateKeyPairs + ) -> TLSConfig.Server { + switch transportKind { + case .posix: + return .posix( + .tls( + .defaults( + certificateChain: [.bytes(certificateKeyPairs.server.certificate, format: .der)], + privateKey: .bytes(certificateKeyPairs.server.key, format: .der) + ) + ) + ) + } + } + + func makeMTLSServerTLSConfig( + for transportKind: TransportKind, + certificateKeyPairs: SelfSignedCertificateKeyPairs, + includeClientCertificateInTrustRoots: Bool + ) -> TLSConfig.Server { + switch transportKind { + case .posix: + return .posix( + .tls( + .mTLS( + certificateChain: [.bytes(certificateKeyPairs.server.certificate, format: .der)], + privateKey: .bytes(certificateKeyPairs.server.key, format: .der) + ) { + if includeClientCertificateInTrustRoots { + $0.trustRoots = .certificates([ + .bytes(certificateKeyPairs.client.certificate, format: .der) + ]) + } + } + ) + ) + } + } + + func withClientAndServer( + clientTLSConfig: TLSConfig.Client, + serverTLSConfig: TLSConfig.Server, + _ test: (ControlClient) async throws -> Void + ) async throws { + try await withThrowingDiscardingTaskGroup { group in + let server = self.makeServer(tlsConfig: serverTLSConfig) + + group.addTask { + try await server.serve() + } + + guard let address = try await server.listeningAddress?.ipv4 else { + Issue.record("Unexpected address to connect to") + return + } + let target: any ResolvableTarget = .ipv4(host: address.host, port: address.port) + let client = try self.makeClient(tlsConfig: clientTLSConfig, target: target) + + group.addTask { + try await client.run() + } + + let control = ControlClient(wrapping: client) + try await test(control) + + server.beginGracefulShutdown() + client.beginGracefulShutdown() + } + } + + private func makeServer(tlsConfig: TLSConfig.Server) -> GRPCServer { + let services = [ControlService()] + + switch tlsConfig { + case .posix(let transportSecurity): + let server = GRPCServer( + transport: .http2NIOPosix( + address: .ipv4(host: "127.0.0.1", port: 0), + config: .defaults(transportSecurity: transportSecurity) + ), + services: services + ) + + return server + } + } + + private func makeClient( + tlsConfig: TLSConfig.Client, + target: any ResolvableTarget + ) throws -> GRPCClient { + let transport: any ClientTransport + + switch tlsConfig { + case .posix(let transportSecurity): + transport = try HTTP2ClientTransport.Posix( + target: target, + config: .defaults(transportSecurity: transportSecurity) { config in + config.backoff.initial = .milliseconds(100) + config.backoff.multiplier = 1 + config.backoff.jitter = 0 + }, + serviceConfig: ServiceConfig() + ) + } + + return GRPCClient(transport: transport) + } + + private func executeUnaryRPC(control: ControlClient) async throws { + let input = ControlInput.with { $0.numberOfMessages = 1 } + let request = ClientRequest(message: input) + try await control.unary(request: request) { response in + #expect(throws: Never.self) { try response.message } + } + } +} + +struct SelfSignedCertificateKeyPairs { + struct CertificateKeyPair { + let certificate: [UInt8] + let key: [UInt8] + } + + let server: CertificateKeyPair + let client: CertificateKeyPair + + init() throws { + let server = try Self.makeSelfSignedDERCertificateAndPrivateKey(name: "Server Certificate") + let client = try Self.makeSelfSignedDERCertificateAndPrivateKey(name: "Client Certificate") + + self.server = CertificateKeyPair(certificate: server.cert, key: server.key) + self.client = CertificateKeyPair(certificate: client.cert, key: client.key) + } + + private static func makeSelfSignedDERCertificateAndPrivateKey( + name: String + ) throws -> (cert: [UInt8], key: [UInt8]) { + let swiftCryptoKey = P256.Signing.PrivateKey() + let key = Certificate.PrivateKey(swiftCryptoKey) + let subjectName = try DistinguishedName { CommonName(name) } + let issuerName = subjectName + let now = Date() + let extensions = try Certificate.Extensions { + Critical( + BasicConstraints.isCertificateAuthority(maxPathLength: nil) + ) + Critical( + KeyUsage(digitalSignature: true, keyCertSign: true) + ) + Critical( + try ExtendedKeyUsage([.serverAuth, .clientAuth]) + ) + SubjectAlternativeNames([.dnsName("localhost")]) + } + let certificate = try Certificate( + version: .v3, + serialNumber: Certificate.SerialNumber(), + publicKey: key.publicKey, + notValidBefore: now.addingTimeInterval(-60 * 60), + notValidAfter: now.addingTimeInterval(60 * 60 * 24 * 365), + issuer: issuerName, + subject: subjectName, + signatureAlgorithm: .ecdsaWithSHA256, + extensions: extensions, + issuerPrivateKey: key + ) + + var serializer = DER.Serializer() + try serializer.serialize(certificate) + + let certBytes = serializer.serializedBytes + let keyBytes = try key.serializeAsPEM().derBytes + return (certBytes, keyBytes) + } +}