Browse Source

Add support for TLS on H2 NIOTS server transport (#2040)

## Motivation
We currently have a NIOTS server transport implementation in gRPC v2,
but it doesn't support TLS.

## Modifications
This PR adds support for TLS in the NIOTS-backed HTTP/2 implementation
of the server transport for gRPC v2.
It also adds support for ALPN, to validate that the negotiated protocol,
if required, is HTTP2 or `grpc-exp`. If it's not, an error will be
fired/the channel will be closed, since we don't support H1.

## Result
We now support TLS/ALPN when using the NIOTS server transport in gRPC
V2.
Gus Cairo 1 year ago
parent
commit
f8184b8715

+ 78 - 11
Sources/GRPCHTTP2TransportNIOTransportServices/HTTP2ServerTransport+TransportServices.swift

@@ -19,9 +19,10 @@ public import GRPCCore
 public import NIOTransportServices  // has to be public because of default argument value in init
 public import GRPCHTTP2Core
 
-internal import NIOCore
-internal import NIOExtras
-internal import NIOHTTP2
+private import NIOCore
+private import NIOExtras
+private import NIOHTTP2
+private import Network
 
 private import Synchronization
 
@@ -171,7 +172,25 @@ extension HTTP2ServerTransport {
         }
       }
 
-      let serverChannel = try await NIOTSListenerBootstrap(group: self.eventLoopGroup)
+      let bootstrap: NIOTSListenerBootstrap
+
+      let requireALPN: Bool
+      let scheme: Scheme
+      switch self.config.transportSecurity.wrapped {
+      case .plaintext:
+        requireALPN = false
+        scheme = .http
+        bootstrap = NIOTSListenerBootstrap(group: self.eventLoopGroup)
+
+      case .tls(let tlsConfig):
+        requireALPN = tlsConfig.requireALPN
+        scheme = .https
+        bootstrap = NIOTSListenerBootstrap(group: self.eventLoopGroup)
+          .tlsOptions(try NWProtocolTLS.Options(tlsConfig))
+      }
+
+      let serverChannel =
+        try await bootstrap
         .serverChannelOption(
           ChannelOptions.socketOption(.so_reuseaddr),
           value: 1
@@ -190,8 +209,8 @@ extension HTTP2ServerTransport {
               connectionConfig: self.config.connection,
               http2Config: self.config.http2,
               rpcConfig: self.config.rpc,
-              requireALPN: false,
-              scheme: .http
+              requireALPN: requireALPN,
+              scheme: scheme
             )
           }
         }
@@ -292,41 +311,55 @@ extension HTTP2ServerTransport.TransportServices {
   public struct Config: Sendable {
     /// Compression configuration.
     public var compression: HTTP2ServerTransport.Config.Compression
+
     /// Connection configuration.
     public var connection: HTTP2ServerTransport.Config.Connection
+
     /// HTTP2 configuration.
     public var http2: HTTP2ServerTransport.Config.HTTP2
+
     /// RPC configuration.
     public var rpc: HTTP2ServerTransport.Config.RPC
 
+    /// The transport's security.
+    public var transportSecurity: TransportSecurity
+
     /// Construct a new `Config`.
     /// - Parameters:
     ///   - compression: Compression configuration.
     ///   - connection: Connection configuration.
     ///   - http2: HTTP2 configuration.
     ///   - rpc: RPC configuration.
+    ///   - transportSecurity: The transport's security configuration.
     public init(
       compression: HTTP2ServerTransport.Config.Compression,
       connection: HTTP2ServerTransport.Config.Connection,
       http2: HTTP2ServerTransport.Config.HTTP2,
-      rpc: HTTP2ServerTransport.Config.RPC
+      rpc: HTTP2ServerTransport.Config.RPC,
+      transportSecurity: TransportSecurity
     ) {
       self.compression = compression
       self.connection = connection
       self.http2 = http2
       self.rpc = rpc
+      self.transportSecurity = transportSecurity
     }
 
     /// Default values for the different configurations.
     ///
-    /// - Parameter configure: A closure which allows you to modify the defaults before
-    ///     returning them.
-    public static func defaults(configure: (_ config: inout Self) -> Void = { _ in }) -> Self {
+    /// - Parameters:
+    ///   - transportSecurity: The transport's security configuration.
+    ///   - configure: A closure which allows you to modify the defaults before returning them.
+    public static func defaults(
+      transportSecurity: TransportSecurity,
+      configure: (_ config: inout Self) -> Void = { _ in }
+    ) -> Self {
       var config = Self(
         compression: .defaults,
         connection: .defaults,
         http2: .defaults,
-        rpc: .defaults
+        rpc: .defaults,
+        transportSecurity: transportSecurity
       )
       configure(&config)
       return config
@@ -396,4 +429,38 @@ extension ServerTransport where Self == HTTP2ServerTransport.TransportServices {
     )
   }
 }
+
+@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
+extension NWProtocolTLS.Options {
+  convenience init(_ tlsConfig: HTTP2ServerTransport.TransportServices.Config.TLS) throws {
+    self.init()
+
+    guard let sec_identity = sec_identity_create(try tlsConfig.identityProvider()) else {
+      throw RuntimeError(
+        code: .transportError,
+        message: """
+          There was an issue creating the SecIdentity required to set up TLS. \
+          Please check your TLS configuration.
+          """
+      )
+    }
+
+    sec_protocol_options_set_local_identity(
+      self.securityProtocolOptions,
+      sec_identity
+    )
+
+    sec_protocol_options_set_min_tls_protocol_version(
+      self.securityProtocolOptions,
+      .TLSv12
+    )
+
+    for `protocol` in ["grpc-exp", "h2"] {
+      sec_protocol_options_add_tls_application_protocol(
+        self.securityProtocolOptions,
+        `protocol`
+      )
+    }
+  }
+}
 #endif

+ 63 - 0
Sources/GRPCHTTP2TransportNIOTransportServices/TLSConfig.swift

@@ -0,0 +1,63 @@
+/*
+ * 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.
+ */
+
+#if canImport(Network)
+public import Network
+
+@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
+extension HTTP2ServerTransport.TransportServices.Config {
+  /// The security configuration for this connection.
+  public struct TransportSecurity: Sendable {
+    package enum Wrapped: Sendable {
+      case plaintext
+      case tls(TLS)
+    }
+
+    package let wrapped: Wrapped
+
+    /// This connection is plaintext: no encryption will take place.
+    public static let plaintext = Self(wrapped: .plaintext)
+
+    /// This connection will use TLS.
+    public static func tls(_ tls: TLS) -> Self {
+      Self(wrapped: .tls(tls))
+    }
+  }
+
+  public struct TLS: Sendable {
+    /// A provider for the `SecIdentity` to be used when setting up TLS.
+    public var identityProvider: @Sendable () throws -> SecIdentity
+
+    /// Whether ALPN is required.
+    ///
+    /// 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, with some values defaulted:
+    /// - `requireALPN` equals `false`
+    ///
+    /// - Returns: A new HTTP2 NIO Transport Services transport TLS config.
+    public static func defaults(
+      identityProvider: @Sendable @escaping () throws -> SecIdentity
+    ) -> Self {
+      Self(
+        identityProvider: identityProvider,
+        requireALPN: false
+      )
+    }
+  }
+}
+#endif

+ 63 - 5
Tests/GRPCHTTP2TransportTests/HTTP2TransportNIOTransportServicesTests.swift

@@ -22,10 +22,59 @@ import XCTest
 
 @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
 final class HTTP2TransportNIOTransportServicesTests: XCTestCase {
+  private static let p12bundleURL = URL(fileURLWithPath: #filePath)
+    .deletingLastPathComponent()  // (this file)
+    .deletingLastPathComponent()  // GRPCHTTP2TransportTests
+    .deletingLastPathComponent()  // Tests
+    .appendingPathComponent("Sources")
+    .appendingPathComponent("GRPCSampleData")
+    .appendingPathComponent("bundle")
+    .appendingPathExtension("p12")
+
+  @Sendable private static func loadIdentity() throws -> SecIdentity {
+    let data = try Data(contentsOf: Self.p12bundleURL)
+
+    var externalFormat = SecExternalFormat.formatUnknown
+    var externalItemType = SecExternalItemType.itemTypeUnknown
+    let passphrase = "password" as CFTypeRef
+    var exportKeyParams = SecItemImportExportKeyParameters()
+    exportKeyParams.passphrase = Unmanaged.passUnretained(passphrase)
+    var items: CFArray?
+
+    let status = SecItemImport(
+      data as CFData,
+      "bundle.p12" as CFString,
+      &externalFormat,
+      &externalItemType,
+      SecItemImportExportFlags(rawValue: 0),
+      &exportKeyParams,
+      nil,
+      &items
+    )
+
+    if status != errSecSuccess {
+      XCTFail(
+        """
+        Unable to load identity from '\(Self.p12bundleURL)'. \
+        SecItemImport failed with status \(status)
+        """
+      )
+    } else if items == nil {
+      XCTFail(
+        """
+        Unable to load identity from '\(Self.p12bundleURL)'. \
+        SecItemImport failed.
+        """
+      )
+    }
+
+    return ((items! as NSArray)[0] as! SecIdentity)
+  }
+
   func testGetListeningAddress_IPv4() async throws {
     let transport = GRPCHTTP2Core.HTTP2ServerTransport.TransportServices(
       address: .ipv4(host: "0.0.0.0", port: 0),
-      config: .defaults()
+      config: .defaults(transportSecurity: .plaintext)
     )
 
     try await withThrowingDiscardingTaskGroup { group in
@@ -45,7 +94,7 @@ final class HTTP2TransportNIOTransportServicesTests: XCTestCase {
   func testGetListeningAddress_IPv6() async throws {
     let transport = GRPCHTTP2Core.HTTP2ServerTransport.TransportServices(
       address: .ipv6(host: "::1", port: 0),
-      config: .defaults()
+      config: .defaults(transportSecurity: .plaintext)
     )
 
     try await withThrowingDiscardingTaskGroup { group in
@@ -65,7 +114,7 @@ final class HTTP2TransportNIOTransportServicesTests: XCTestCase {
   func testGetListeningAddress_UnixDomainSocket() async throws {
     let transport = GRPCHTTP2Core.HTTP2ServerTransport.TransportServices(
       address: .unixDomainSocket(path: "/tmp/niots-uds-test"),
-      config: .defaults()
+      config: .defaults(transportSecurity: .plaintext)
     )
     defer {
       // NIOTS does not unlink the UDS on close.
@@ -91,7 +140,7 @@ final class HTTP2TransportNIOTransportServicesTests: XCTestCase {
   func testGetListeningAddress_InvalidAddress() async {
     let transport = GRPCHTTP2Core.HTTP2ServerTransport.TransportServices(
       address: .unixDomainSocket(path: "/this/should/be/an/invalid/path"),
-      config: .defaults()
+      config: .defaults(transportSecurity: .plaintext)
     )
 
     try? await withThrowingDiscardingTaskGroup { group in
@@ -120,7 +169,7 @@ final class HTTP2TransportNIOTransportServicesTests: XCTestCase {
   func testGetListeningAddress_StoppedListening() async throws {
     let transport = GRPCHTTP2Core.HTTP2ServerTransport.TransportServices(
       address: .ipv4(host: "0.0.0.0", port: 0),
-      config: .defaults()
+      config: .defaults(transportSecurity: .plaintext)
     )
 
     try? await withThrowingDiscardingTaskGroup { group in
@@ -149,5 +198,14 @@ final class HTTP2TransportNIOTransportServicesTests: XCTestCase {
       }
     }
   }
+
+  func testTLSConfig_Defaults() throws {
+    let identityProvider = Self.loadIdentity
+    let grpcTLSConfig = HTTP2ServerTransport.TransportServices.Config.TLS.defaults(
+      identityProvider: identityProvider
+    )
+    XCTAssertEqual(try grpcTLSConfig.identityProvider(), try identityProvider())
+    XCTAssertEqual(grpcTLSConfig.requireALPN, false)
+  }
 }
 #endif

+ 1 - 1
Tests/GRPCHTTP2TransportTests/HTTP2TransportTests.swift

@@ -166,7 +166,7 @@ final class HTTP2TransportTests: XCTestCase {
       let server = GRPCServer(
         transport: .http2NIOTS(
           address: .ipv4(host: "127.0.0.1", port: 0),
-          config: .defaults {
+          config: .defaults(transportSecurity: .plaintext) {
             $0.compression.enabledAlgorithms = compression
           }
         ),