Browse Source

Add support for NIOCertificateReloading (#104)

Motivation:

swift-nio-extras recently added support for certificate reloaders which
can keep cert chain and private keys up-to-date in the background and
provide current values when asked for them. We should expose an API
allowing these to be configured.

Modifications:

- Expose API on the TLS configs for posix-based transports to create the
TLS config using a cert reloader

Result:

Users can create TLS configs using the new cert relader

---------

Co-authored-by: Rick Newton-Rogers <rnro@apple.com>
George Barnett 8 months ago
parent
commit
d90cac0fae

+ 2 - 1
Package.swift

@@ -55,7 +55,7 @@ let dependencies: [Package.Dependency] = [
   ),
   .package(
     url: "https://github.com/apple/swift-nio-extras.git",
-    from: "1.4.0"
+    from: "1.27.0"
   ),
   .package(
     url: "https://github.com/apple/swift-certificates.git",
@@ -117,6 +117,7 @@ let targets: [Target] = [
       .product(name: "NIOSSL", package: "swift-nio-ssl"),
       .product(name: "X509", package: "swift-certificates"),
       .product(name: "SwiftASN1", package: "swift-asn1"),
+      .product(name: "NIOCertificateReloading", package: "swift-nio-extras"),
     ],
     swiftSettings: defaultSwiftSettings
   ),

+ 31 - 0
Sources/GRPCNIOTransportCore/TLSConfig+Internal.swift

@@ -26,3 +26,34 @@ extension TLSConfig.PrivateKeySource {
     Self(wrapped: .transportSpecific(source))
   }
 }
+
+extension TLSConfig.CertificateSource {
+  /// A type-erased transport specific certificate source.
+  ///
+  /// `TLSConfig.CertificateSource` is from the core module which means it can't take a NIOSSL
+  /// or NIOTransportServices dependency. In order to support more sources a transport-specific
+  /// erased source is provided as non-public API.
+  package struct TransportSpecific: Sendable, Equatable {
+    package var wrapped: any Sendable
+    private let isEqualTo: @Sendable (TransportSpecific) -> Bool
+
+    package init<Value: Sendable & Equatable>(_ wrapped: Value) {
+      self.wrapped = wrapped
+      self.isEqualTo = { other in
+        if let otherValue = other.wrapped as? Value {
+          return otherValue == wrapped
+        } else {
+          return false
+        }
+      }
+    }
+
+    package static func == (lhs: Self, rhs: Self) -> Bool {
+      lhs.isEqualTo(rhs)
+    }
+  }
+
+  package static func transportSpecific(_ value: TransportSpecific) -> Self {
+    return Self(wrapped: .transportSpecific(value))
+  }
+}

+ 1 - 0
Sources/GRPCNIOTransportCore/TLSConfig.swift

@@ -34,6 +34,7 @@ public enum TLSConfig: Sendable {
     package enum Wrapped: Equatable {
       case file(path: String, format: SerializationFormat)
       case bytes(bytes: [UInt8], format: SerializationFormat)
+      case transportSpecific(TransportSpecific)
     }
 
     package let wrapped: Wrapped

+ 138 - 0
Sources/GRPCNIOTransportHTTP2Posix/Config+TLS.swift

@@ -14,6 +14,8 @@
  * limitations under the License.
  */
 
+private import GRPCCore
+public import NIOCertificateReloading
 public import NIOSSL
 
 extension HTTP2ServerTransport.Posix {
@@ -53,6 +55,39 @@ extension HTTP2ServerTransport.Posix {
       return .tls(tlsConfig)
     }
 
+    /// Create a new TLS config using a certificate reloader to provide the certificate chain
+    /// and private key.
+    ///
+    /// The reloader must provide an initial certificate chain and private key. If you already
+    /// have an initial certificate chain and private key you can use
+    /// ``tls(certificateChain:privateKey:configure:)`` and set the certificate reloader via
+    /// the `configure` callback.
+    ///
+    /// The defaults include setting:
+    /// - `clientCertificateVerificationMode` to `doNotVerify`,
+    /// - `trustRoots` to `systemDefault`, and
+    /// - `requireALPN` to `false`.
+    ///
+    /// - Parameters:
+    ///   - reloader: A certificate reloader which has been primed with an initial certificate chain
+    ///       and private key.
+    ///   - configure: A closure which allows you to modify the defaults before returning them.
+    /// - Throws: If the reloader doesn't provide an initial certificate chain or private key.
+    /// - Returns: A new HTTP2 NIO Posix transport TLS config.
+    public static func tls(
+      certificateReloader reloader: any CertificateReloader,
+      configure: (_ config: inout TLS) -> Void = { _ in }
+    ) throws -> Self {
+      let (certificateChain, privateKey) = try reloader.checkPrimed()
+      return .tls(
+        certificateChain: certificateChain.map { source in .nioSSLCertificateSource(source) },
+        privateKey: .nioSSLSpecific(.privateKey(privateKey))
+      ) { config in
+        config.certificateReloader = reloader
+        configure(&config)
+      }
+    }
+
     /// Secure the connection with mutual TLS.
     ///
     /// - Parameters:
@@ -71,6 +106,39 @@ extension HTTP2ServerTransport.Posix {
       )
       return .tls(tlsConfig)
     }
+
+    /// Create a new TLS config suitable for mTLS using a certificate reloader to provide the
+    /// certificate chain and private key.
+    ///
+    /// The reloader must provide an initial certificate chain and private key. If you already
+    /// have an initial certificate chain and private key you can use
+    /// ``mTLS(certificateChain:privateKey:configure:)`` and set the certificate reloader via
+    /// the `configure` callback.
+    ///
+    /// The defaults include setting:
+    /// - `clientCertificateVerificationMode` to `noHostnameVerification`,
+    /// - `trustRoots` to `systemDefault`, and
+    /// - `requireALPN` to `false`.
+    ///
+    /// - Parameters:
+    ///   - reloader: A certificate reloader which has been primed with an initial certificate chain
+    ///       and private key.
+    ///   - configure: A closure which allows you to modify the defaults before returning them.
+    /// - Throws: If the reloader doesn't provide an initial certificate chain or private key.
+    /// - Returns: A new HTTP2 NIO Posix transport TLS config.
+    public static func mTLS(
+      certificateReloader reloader: any CertificateReloader,
+      configure: (_ config: inout TLS) -> Void = { _ in }
+    ) throws -> Self {
+      let (certificateChain, privateKey) = try reloader.checkPrimed()
+      return .mTLS(
+        certificateChain: certificateChain.map { source in .nioSSLCertificateSource(source) },
+        privateKey: .nioSSLSpecific(.privateKey(privateKey))
+      ) { config in
+        config.certificateReloader = reloader
+        configure(&config)
+      }
+    }
   }
 }
 
@@ -93,6 +161,10 @@ extension HTTP2ServerTransport.Posix.TransportSecurity {
     /// If this is set to `true` but the client does not support ALPN, then the connection will be rejected.
     public var requireALPN: Bool
 
+    /// A certificate reloader providing the current certificate chain and private key to
+    /// use at that point in time.
+    public var certificateReloader: (any CertificateReloader)?
+
     /// Create a new HTTP2 NIO Posix server transport TLS config.
     /// - Parameters:
     ///   - certificateChain: The certificates the server will offer during negotiation.
@@ -220,6 +292,38 @@ extension HTTP2ClientTransport.Posix {
       )
       return .tls(tlsConfig)
     }
+
+    /// Create a new TLS config suitable for mTLS using a certificate reloader to provide the
+    /// certificate chain and private key.
+    ///
+    /// The reloader must provide an initial certificate chain and private key. If you have already
+    /// have an initial certificate chain and private key you can use
+    /// ``mTLS(certificateChain:privateKey:configure:)`` and set the certificate reloader via
+    /// the `configure` callback.
+    ///
+    /// The defaults include setting:
+    /// - `trustRoots` to `systemDefault`, and
+    /// - `serverCertificateVerification` to `fullVerification`.
+    ///
+    /// - Parameters:
+    ///   - reloader: A certificate reloader which has been primed with an initial certificate chain
+    ///       and private key.
+    ///   - configure: A closure which allows you to modify the defaults before returning them.
+    /// - Throws: If the reloader doesn't provide an initial certificate chain or private key.
+    /// - Returns: A new HTTP2 NIO Posix transport TLS config.
+    public static func mTLS(
+      certificateReloader reloader: any CertificateReloader,
+      configure: (_ config: inout TLS) -> Void = { _ in }
+    ) throws -> Self {
+      let (certificateChain, privateKey) = try reloader.checkPrimed()
+      return .mTLS(
+        certificateChain: certificateChain.map { source in .nioSSLCertificateSource(source) },
+        privateKey: .nioSSLSpecific(.privateKey(privateKey))
+      ) { config in
+        config.certificateReloader = reloader
+        configure(&config)
+      }
+    }
   }
 }
 
@@ -237,6 +341,10 @@ extension HTTP2ClientTransport.Posix.TransportSecurity {
     /// The trust roots to be used when verifying server certificates.
     public var trustRoots: TLSConfig.TrustRootsSource
 
+    /// A certificate reloader providing the current certificate chain and private key to
+    /// use at that point in time.
+    public var certificateReloader: (any CertificateReloader)?
+
     /// Create a new HTTP2 NIO Posix client transport TLS config.
     /// - Parameters:
     ///   - certificateChain: The certificates the client will offer during negotiation.
@@ -323,3 +431,33 @@ extension TLSConfig.PrivateKeySource {
     .nioSSLSpecific(.customPrivateKey(key))
   }
 }
+
+extension TLSConfig.CertificateSource {
+  internal static func nioSSLCertificateSource(_ wrapped: NIOSSLCertificateSource) -> Self {
+    return .transportSpecific(TransportSpecific(wrapped))
+  }
+}
+
+extension CertificateReloader {
+  fileprivate func checkPrimed() throws -> ([NIOSSLCertificateSource], NIOSSLPrivateKeySource) {
+    func explain(missingItem item: String) -> String {
+      return """
+        No \(item) available. The reloader must provide a certificate chain and private key when \
+        creating a TLS config from a reloader. Ensure the reloader is ready or create a config \
+        with a certificate chain and private key manually and set the certificate reloader \
+        separately.
+        """
+    }
+
+    let override = self.sslContextConfigurationOverride
+    guard let certificateChain = override.certificateChain else {
+      throw RPCError(code: .invalidArgument, message: explain(missingItem: "certificate chain"))
+    }
+
+    guard let privateKey = override.privateKey else {
+      throw RPCError(code: .invalidArgument, message: explain(missingItem: "private key"))
+    }
+
+    return (certificateChain, privateKey)
+  }
+}

+ 41 - 0
Sources/GRPCNIOTransportHTTP2Posix/NIOSSL+GRPC.swift

@@ -15,6 +15,8 @@
  */
 
 internal import GRPCCore
+package import GRPCNIOTransportCore
+internal import NIOCertificateReloading
 internal import NIOSSL
 
 extension NIOSSLSerializationFormats {
@@ -60,6 +62,13 @@ extension Sequence<TLSConfig.CertificateSource> {
           }
           certificateSources.append(contentsOf: certificates)
         }
+
+      case .transportSpecific(let specific):
+        if let source = specific.wrapped as? NIOSSLCertificateSource {
+          certificateSources.append(source)
+        } else {
+          fatalError("Invalid certificate source of type \(type(of: specific.wrapped))")
+        }
       }
     }
     return certificateSources
@@ -134,11 +143,35 @@ extension NIOSSLTrustRoots {
             bytes: bytes,
             format: NIOSSLSerializationFormats(serializationFormat)
           )
+
         case .file(let path, let serializationFormat):
           return try NIOSSLCertificate(
             file: path,
             format: NIOSSLSerializationFormats(serializationFormat)
           )
+
+        case .transportSpecific(let specific):
+          guard let source = specific.wrapped as? NIOSSLCertificateSource else {
+            fatalError("Invalid certificate source of type \(type(of: specific.wrapped))")
+          }
+
+          switch source {
+          case .certificate(let certificate):
+            return certificate
+
+          case .file(let path):
+            switch path.split(separator: ".").last {
+            case "pem":
+              return try NIOSSLCertificate(file: path, format: .pem)
+            case "der":
+              return try NIOSSLCertificate(file: path, format: .der)
+            default:
+              throw RPCError(
+                code: .invalidArgument,
+                message: "Couldn't load certificate from \(path)."
+              )
+            }
+          }
         }
       }
       self = .certificates(certificates)
@@ -178,6 +211,10 @@ extension TLSConfiguration {
     self.certificateVerification = CertificateVerification(tlsConfig.clientCertificateVerification)
     self.trustRoots = try NIOSSLTrustRoots(tlsConfig.trustRoots)
     self.applicationProtocols = ["grpc-exp", "h2"]
+
+    if let reloader = tlsConfig.certificateReloader {
+      self.setCertificateReloader(reloader)
+    }
   }
 
   package init(_ tlsConfig: HTTP2ClientTransport.Posix.TransportSecurity.TLS) throws {
@@ -193,5 +230,9 @@ extension TLSConfiguration {
     self.certificateVerification = CertificateVerification(tlsConfig.serverCertificateVerification)
     self.trustRoots = try NIOSSLTrustRoots(tlsConfig.trustRoots)
     self.applicationProtocols = ["grpc-exp", "h2"]
+
+    if let reloader = tlsConfig.certificateReloader {
+      self.setCertificateReloader(reloader)
+    }
   }
 }

+ 3 - 0
Sources/GRPCNIOTransportHTTP2TransportServices/Config+TLS.swift

@@ -318,6 +318,9 @@ extension TLSConfig.TrustRootsSource {
 
             case .file(_, let format), .bytes(_, let format):
               fatalError("Certificate format must be DER, but was \(format).")
+
+            case .transportSpecific:
+              fatalError("Certificate format must be DER, but was an unsupported type.")
             }
 
             guard let certificate = SecCertificateCreateWithData(nil, certificateBytes as CFData)

+ 44 - 0
Tests/GRPCNIOTransportHTTP2Tests/TLSConfigurationTests.swift

@@ -15,6 +15,7 @@
  */
 
 import GRPCNIOTransportHTTP2
+import NIOCertificateReloading
 import NIOCore
 import NIOSSL
 import Testing
@@ -62,6 +63,49 @@ struct TLSConfigurationTests {
     let privateKey = try #require(tlsConfig.privateKey?.privateKey)
     #expect(privateKey == NIOSSLPrivateKey(customPrivateKey: custom))
   }
+
+  struct StaticCertLoader: CertificateReloader {
+    var sslContextConfigurationOverride: NIOSSLContextConfigurationOverride {
+      var override = NIOSSLContextConfigurationOverride()
+      override.certificateChain = []
+      override.privateKey = .privateKey(NIOSSLPrivateKey(customPrivateKey: NoOpCustomPrivateKey()))
+      return override
+    }
+  }
+
+  @Test("Client cert reloader is set")
+  func clientCertificateReloader() throws {
+    let config = try HTTP2ClientTransport.Posix.TransportSecurity.mTLS(
+      certificateReloader: StaticCertLoader()
+    )
+    let tls = try #require(config.tls)
+    let tlsConfig = try TLSConfiguration(tls)
+    let privateKey = try #require(tlsConfig.privateKey?.privateKey)
+    #expect(privateKey == NIOSSLPrivateKey(customPrivateKey: NoOpCustomPrivateKey()))
+    #expect(tlsConfig.certificateChain.isEmpty)
+    #expect(tlsConfig.sslContextCallback != nil)
+  }
+
+  @Test("Server cert reloader is set", arguments: [false, true])
+  func serverCertificateReloader(isMTLS: Bool) throws {
+    let config: HTTP2ServerTransport.Posix.TransportSecurity
+    if isMTLS {
+      config = try HTTP2ServerTransport.Posix.TransportSecurity.mTLS(
+        certificateReloader: StaticCertLoader()
+      )
+    } else {
+      config = try HTTP2ServerTransport.Posix.TransportSecurity.tls(
+        certificateReloader: StaticCertLoader()
+      )
+    }
+
+    let tls = try #require(config.tls)
+    let tlsConfig = try TLSConfiguration(tls)
+    let privateKey = try #require(tlsConfig.privateKey?.privateKey)
+    #expect(privateKey == NIOSSLPrivateKey(customPrivateKey: NoOpCustomPrivateKey()))
+    #expect(tlsConfig.certificateChain.isEmpty)
+    #expect(tlsConfig.sslContextCallback != nil)
+  }
 }
 
 extension HTTP2ClientTransport.Posix.TransportSecurity {