Przeglądaj źródła

Add E2E tests with TLS enabled (#12)

This PR adds E2E tests for `NIOPosix`'s transport implementation with
TLS enabled. It also adds some additional API to create TLS configs.
Gus Cairo 1 rok temu
rodzic
commit
b0d3ba05b7

+ 7 - 0
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"),
     ]
   )
 ]

+ 81 - 9
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
     }
   }
 }

+ 13 - 4
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)
     }
   }
 }

+ 432 - 0
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)
+  }
+}