Browse Source

Support custom verification callback configuration (#125)

### Motivation:

When using mTLS, the server might want to handle client authentication
in different ways. NIO offers callbacks to customize the verification
process. These should be available for configuration.

### Modifications:

* Raise NIO SSL version.
* Add a callback configuration property to the TLS configuration and
propagate it to NIO SSL.
* Add new tests for the callback.

### Result:

The callback can be configured when setting up mTLS.
Raphael 4 months ago
parent
commit
af4cc826b3

+ 2 - 2
Package.swift

@@ -51,7 +51,7 @@ let dependencies: [Package.Dependency] = [
   ),
   .package(
     url: "https://github.com/apple/swift-nio-ssl.git",
-    from: "2.33.0"
+    from: "2.34.0"
   ),
   .package(
     url: "https://github.com/apple/swift-nio-extras.git",
@@ -71,7 +71,7 @@ let dependencies: [Package.Dependency] = [
 
 // This adds some build settings which allow us to map "@available(gRPCSwiftNIOTransport 2.x, *)" to
 // the appropriate OS platforms.
-let nextMinorVersion = 1
+let nextMinorVersion = 2
 let availabilitySettings: [SwiftSetting] = (0 ... nextMinorVersion).map { minor in
   let name = "gRPCSwiftNIOTransport"
   let version = "2.\(minor)"

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

@@ -16,6 +16,7 @@
 
 private import GRPCCore
 public import NIOCertificateReloading
+public import NIOCore
 public import NIOSSL
 
 @available(gRPCSwiftNIOTransport 2.0, *)
@@ -167,6 +168,17 @@ extension HTTP2ServerTransport.Posix.TransportSecurity {
     /// use at that point in time.
     public var certificateReloader: (any CertificateReloader)?
 
+    /// Override the certificate verification with a custom callback that must return the verified certificate chain on success.
+    /// Note: The callback is only used when `clientCertificateVerification` is *not* set to `noVerification`!
+    @available(gRPCSwiftNIOTransport 2.2, *)
+    public var customVerificationCallback:
+      (
+        @Sendable (
+          _ certificates: [NIOSSLCertificate],
+          _ promise: EventLoopPromise<NIOSSLVerificationResultWithMetadata>
+        ) -> Void
+      )?
+
     /// Create a new HTTP2 NIO Posix server transport TLS config.
     /// - Parameters:
     ///   - certificateChain: The certificates the server will offer during negotiation.

+ 20 - 3
Sources/GRPCNIOTransportHTTP2Posix/HTTP2ServerTransport+Posix.swift

@@ -67,10 +67,17 @@ extension HTTP2ServerTransport {
         serverQuiescingHelper: ServerQuiescingHelper
       ) async throws -> NIOAsyncChannel<AcceptedChannel, Never> {
         let sslContext: NIOSSLContext?
+        let customVerificationCallback:
+          (
+            @Sendable (
+              [NIOSSLCertificate], EventLoopPromise<NIOSSLVerificationResultWithMetadata>
+            ) -> Void
+          )?
 
         switch self.transportSecurity.wrapped {
         case .plaintext:
           sslContext = nil
+          customVerificationCallback = nil
         case .tls(let tlsConfig):
           do {
             sslContext = try NIOSSLContext(configuration: TLSConfiguration(tlsConfig))
@@ -81,6 +88,7 @@ extension HTTP2ServerTransport {
               cause: error
             )
           }
+          customVerificationCallback = tlsConfig.customVerificationCallback
         }
 
         let serverChannel = try await ServerBootstrap(group: eventLoopGroup)
@@ -99,9 +107,18 @@ extension HTTP2ServerTransport {
           .bind(to: address) { channel in
             channel.eventLoop.makeCompletedFuture {
               if let sslContext {
-                try channel.pipeline.syncOperations.addHandler(
-                  NIOSSLServerHandler(context: sslContext)
-                )
+                if let callback = customVerificationCallback {
+                  try channel.pipeline.syncOperations.addHandler(
+                    NIOSSLServerHandler(
+                      context: sslContext,
+                      customVerificationCallbackWithMetadata: callback
+                    )
+                  )
+                } else {
+                  try channel.pipeline.syncOperations.addHandler(
+                    NIOSSLServerHandler(context: sslContext)
+                  )
+                }
               }
 
               let requireALPN: Bool

+ 187 - 0
Tests/GRPCNIOTransportHTTP2Tests/HTTP2TransportTLSEnabledTests.swift

@@ -18,9 +18,11 @@ import Foundation
 import GRPCCore
 import GRPCNIOTransportHTTP2Posix
 import GRPCNIOTransportHTTP2TransportServices
+import NIOCore
 import NIOSSL
 import SwiftASN1
 import Testing
+import X509
 
 #if canImport(Network)
 import Network
@@ -185,6 +187,165 @@ struct HTTP2TransportTLSEnabledTests {
     }
   }
 
+  @Test("Custom certification callbacks are used for verification.")
+  @available(gRPCSwiftNIOTransport 2.0, *)
+  func testRPC_mTLS_customVerificationCallback_OK() async throws {
+    // Create a new certificate chain that has 4 certificate/key pairs: root, intermediate, client, server
+    let certificateChain = try CertificateChain()
+    let certificatesExpectedInCallback = [certificateChain.client.certificate]
+    let filePaths = try certificateChain.writeToTemp()
+
+    let clientConfig = self.makeMTLSClientConfig(
+      certificatePath: filePaths.clientCert,
+      keyPath: filePaths.clientKey,
+      trustRootsPath: filePaths.trustRoots,
+      serverHostname: CertificateChain.serverName
+    )
+
+    // The confirmation lets us check that the callback is used.
+    try await confirmation(expectedCount: 1) { confirmation in
+      let serverConfig = self.makeMTLSServerConfigWithCallback(
+        certificatePath: filePaths.serverCert,
+        keyPath: filePaths.serverKey,
+        trustRootsPath: filePaths.trustRoots
+      ) { certificates, promise in
+        let presentedCertificates = certificates.map {
+          try! Certificate(derEncoded: $0.toDERBytes())
+        }
+        #expect(certificatesExpectedInCallback == presentedCertificates)
+        // "Verify" the chain and set the certificate.
+        promise.succeed(
+          .certificateVerified(VerificationMetadata(ValidatedCertificateChain(certificates)))
+        )
+        // This should be called once.
+        confirmation.confirm()
+      }
+
+      // Run the test
+      try await self.withClientAndServer(
+        clientConfig: clientConfig,
+        serverConfig: serverConfig
+      ) { control in
+        await #expect(throws: Never.self) {
+          try await self.executeUnaryRPC(control: control)
+        }
+      }
+    }
+  }
+
+  @Test("Custom certification callbacks are not called when verification is disabled.")
+  @available(gRPCSwiftNIOTransport 2.0, *)
+  func testRPC_mTLS_customVerificationCallback_notCalledWhenNoVerificationIsConfigured()
+    async throws
+  {
+    // Create a new certificate chain that has 4 certificate/key pairs: root, intermediate, client, server
+    let certificateChain = try CertificateChain()
+    let certificatesExpectedInCallback = [certificateChain.client.certificate]
+    let filePaths = try certificateChain.writeToTemp()
+
+    let clientConfig = self.makeMTLSClientConfig(
+      certificatePath: filePaths.clientCert,
+      keyPath: filePaths.clientKey,
+      trustRootsPath: filePaths.trustRoots,
+      serverHostname: CertificateChain.serverName
+    )
+
+    // The confirmation lets us check that the callback is not used.
+    try await confirmation(expectedCount: 0) { confirmation in
+      let serverConfig = self.makeMTLSServerConfigWithCallback(
+        certificatePath: filePaths.serverCert,
+        keyPath: filePaths.serverKey,
+        trustRootsPath: filePaths.trustRoots,
+        certificateVerification: TLSConfig.CertificateVerification.noVerification
+      ) { certificates, promise in
+        let presentedCertificates = certificates.map {
+          try! Certificate(derEncoded: $0.toDERBytes())
+        }
+        #expect(certificatesExpectedInCallback == presentedCertificates)
+        // "Verify" the chain and set the certificate.
+        promise.succeed(
+          .certificateVerified(VerificationMetadata(ValidatedCertificateChain(certificates)))
+        )
+        // We expect this never to be called.
+        confirmation()
+      }
+
+      // Run the test
+      try await self.withClientAndServer(
+        clientConfig: clientConfig,
+        serverConfig: serverConfig
+      ) { control in
+        await #expect(throws: Never.self) {
+          try await self.executeUnaryRPC(control: control)
+        }
+      }
+    }
+  }
+
+  @Test("mTLS custom callback verification failure leads to denied authentication")
+  @available(gRPCSwiftNIOTransport 2.0, *)
+  // Verification should fail because the custom hostname is missing on the client.
+  func testRPC_mTLS_customVerificationCallback_Failure() async throws {
+    // Create a new certificate chain that has 4 certificate/key pairs: root, intermediate, client, server
+    let certificateChain = try CertificateChain()
+    let certificatesExpectedInCallback = [certificateChain.client.certificate]
+    let filePaths = try certificateChain.writeToTemp()
+
+    let clientConfig = self.makeMTLSClientConfig(
+      certificatePath: filePaths.clientCert,
+      keyPath: filePaths.clientKey,
+      trustRootsPath: filePaths.trustRoots,
+      serverHostname: CertificateChain.serverName
+    )
+
+    // The confirmation lets us check that the callback is used.
+    await confirmation { confirmation in
+      let serverConfig = self.makeMTLSServerConfigWithCallback(
+        certificatePath: filePaths.serverCert,
+        keyPath: filePaths.serverKey,
+        trustRootsPath: filePaths.trustRoots
+      ) { certificates, promise in
+        let presentedCertificates = certificates.map {
+          try! Certificate(derEncoded: $0.toDERBytes())
+        }
+        #expect(certificatesExpectedInCallback == presentedCertificates)
+        // We are failing the certificate check here by propagating ".failed"!
+        promise.succeed(.failed)
+        confirmation.confirm()
+      }
+
+      // Run the test
+      await #expect {
+        try await self.withClientAndServer(
+          clientConfig: clientConfig,
+          serverConfig: serverConfig
+        ) { control in
+          try await self.executeUnaryRPC(control: control)
+        }
+      } throws: { error in
+        // Check root error ...
+        let rootError = try #require(error as? RPCError)
+        #expect(rootError.code == .unavailable)
+        #expect(
+          rootError.message
+            == "The server accepted the TCP connection but closed the connection before completing the HTTP/2 connection preface."
+        )
+
+        // ... and the its cause.
+        let sslError = try #require(rootError.cause as? BoringSSLError)
+        switch sslError {
+        case .sslError:
+          break
+        default:
+          Issue.record(
+            "Should be a BoringSSLError.sslError error, but was: \(String(describing: rootError.cause))"
+          )
+        }
+        return true
+      }
+    }
+  }
+
   @Test(
     "Error is surfaced when client fails server verification",
     arguments: TransportKind.clientsWithTLS,
@@ -635,6 +796,32 @@ struct HTTP2TransportTLSEnabledTests {
     return .posix(config)
   }
 
+  @available(gRPCSwiftNIOTransport 2.0, *)
+  private func makeMTLSServerConfigWithCallback(
+    certificatePath: String,
+    keyPath: String,
+    trustRootsPath: String,
+    certificateVerification: TLSConfig.CertificateVerification = .noHostnameVerification,
+    customVerificationCallback:
+      @escaping (
+        @Sendable ([NIOSSLCertificate], EventLoopPromise<NIOSSLVerificationResultWithMetadata>) ->
+          Void
+      )
+  ) -> ServerConfig {
+    var config = self.makeDefaultPlaintextPosixServerConfig()
+    config.security = .mTLS(
+      certificateChain: [.file(path: certificatePath, format: .pem)],
+      privateKey: .file(path: keyPath, format: .pem)
+    ) {
+      $0.clientCertificateVerification = certificateVerification
+      $0.trustRoots = .certificates([
+        .file(path: trustRootsPath, format: .pem)
+      ])
+      $0.customVerificationCallback = customVerificationCallback
+    }
+    return .posix(config)
+  }
+
   @available(gRPCSwiftNIOTransport 2.0, *)
   func withClientAndServer(
     clientConfig: ClientConfig,