Browse Source

Enable access to the peer's certificate chain (#126)

### Motivation:

The peer certificate chain can contain relevant information in some mTLS
scenarios. NIO SSL only exposes the validated certificate chain when
using custom verification callbacks. Now that this configuration option
is available here, we can expose this property as well.

### Modifications:

Make the property available and expose it as a validated certificate
chain type from swift-certificates. Add tests to confirm the
implementation.

### Result:

The validated certificate chain is available when using mTLS with a
custom certificate validation callback.
Raphael 4 months ago
parent
commit
50567095cc

+ 1 - 1
Package.swift

@@ -59,7 +59,7 @@ let dependencies: [Package.Dependency] = [
   ),
   .package(
     url: "https://github.com/apple/swift-certificates.git",
-    from: "1.5.0"
+    from: "1.14.0"
   ),
   .package(
     url: "https://github.com/apple/swift-asn1.git",

+ 15 - 1
Sources/GRPCNIOTransportHTTP2Posix/HTTP2ServerTransport+Posix.swift

@@ -189,8 +189,18 @@ extension HTTP2ServerTransport {
         listenerFactory: factory
       ) { channel in
         var context = HTTP2ServerTransport.Posix.Context()
+
         do {
-          if let peerCert = try await channel.nioSSL_peerCertificate().get() {
+          // The validadted certificate chain is only available when using a custom verification callback, while the
+          // peer certificate is only available when using the BoringSSL backend. But if we can get the certificate
+          // chain, we can set the peer certificate (the leaf of the chain) as well.
+          if let peerCertificateChain =
+            try await channel.nioSSL_peerValidatedCertificateChain().get(),
+            let peerCertificateChain = try? X509.ValidatedCertificateChain(peerCertificateChain)
+          {
+            context.peerCertificate = peerCertificateChain.leaf
+            context.peerCertificateChain = peerCertificateChain
+          } else if let peerCert = try await channel.nioSSL_peerCertificate().get() {
             let serialized = try peerCert.toDERBytes()
             let swiftCert = try Certificate(derEncoded: serialized)
             context.peerCertificate = swiftCert
@@ -223,6 +233,10 @@ extension HTTP2ServerTransport.Posix {
     /// The peer certificate (if any) from the mTLS handshake
     public var peerCertificate: Certificate?
 
+    /// The validated peer certificate chain from the mTLS handshake. This is only available when using a custom verification callback.
+    @available(gRPCSwiftNIOTransport 2.2, *)
+    public var peerCertificateChain: X509.ValidatedCertificateChain?
+
     public init() {
     }
   }

+ 31 - 0
Sources/GRPCNIOTransportHTTP2Posix/ValidatedCertificateChain.swift

@@ -0,0 +1,31 @@
+/*
+ * Copyright 2025, 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.
+ */
+
+internal import NIOSSL
+internal import SwiftASN1
+internal import X509
+
+@available(gRPCSwiftNIOTransport 2.2, *)
+extension X509.ValidatedCertificateChain {
+  // The precondition holds because the `NIOSSL.ValidatedCertificateChain` always contains one `NIOSSLCertificate`.
+  init(_ chain: NIOSSL.ValidatedCertificateChain) throws {
+    let certs = try chain.map {
+      let derBytes = try $0.toDERBytes()
+      return try Certificate(derEncoded: derBytes)
+    }
+    self.init(uncheckedCertificateChain: certs)
+  }
+}

+ 87 - 0
Tests/GRPCNIOTransportHTTP2Tests/HTTP2TransportTLSEnabledTests.swift

@@ -65,9 +65,11 @@ struct HTTP2TransportTLSEnabledTests {
   @available(gRPCSwiftNIOTransport 2.0, *)
   final class TransportSpecificInterceptor: ServerInterceptor {
     let clientCert: [UInt8]
+
     init(_ clientCert: [UInt8]) {
       self.clientCert = clientCert
     }
+
     func intercept<Input, Output>(
       request: GRPCCore.StreamingServerRequest<Input>,
       context: GRPCCore.ServerContext,
@@ -346,6 +348,91 @@ struct HTTP2TransportTLSEnabledTests {
     }
   }
 
+  @available(gRPCSwiftNIOTransport 2.2, *)
+  final class ValidatedCertificateChainInterceptor: ServerInterceptor {
+    let expectedCertificateChain: [Certificate]
+
+    init(_ expectedCertificateChain: [Certificate]) {
+      self.expectedCertificateChain = expectedCertificateChain
+    }
+
+    func intercept<Input, Output>(
+      request: GRPCCore.StreamingServerRequest<Input>,
+      context: GRPCCore.ServerContext,
+      next:
+        @Sendable (GRPCCore.StreamingServerRequest<Input>, GRPCCore.ServerContext) async throws
+        -> GRPCCore.StreamingServerResponse<Output>
+    ) async throws -> GRPCCore.StreamingServerResponse<Output>
+    where Input: Sendable, Output: Sendable {
+      let transportSpecific = context.transportSpecific
+      let transportSpecificAsPosixContext = try #require(
+        transportSpecific as? HTTP2ServerTransport.Posix.Context
+      )
+
+      let peerCertificateChain = try #require(
+        transportSpecificAsPosixContext.peerCertificateChain
+      )
+      // The validated certifiacte chain always contains at least one element.
+      #expect(!peerCertificateChain.isEmpty)
+
+      // And these chains should have the same length.
+      #expect(peerCertificateChain.count == self.expectedCertificateChain.count)
+      for (lhs, rhs) in zip(peerCertificateChain, self.expectedCertificateChain) {
+        #expect(lhs == rhs)
+      }
+
+      // leaf and root should match the first and last element of the expected chain.
+      #expect(peerCertificateChain.leaf == self.expectedCertificateChain.first!)
+      #expect(peerCertificateChain.root == self.expectedCertificateChain.last!)
+
+      return try await next(request, context)
+    }
+  }
+
+  @Test(
+    "When using a custom certificate callback the validated certifiate chain of the peer is available."
+  )
+  @available(gRPCSwiftNIOTransport 2.2, *)
+  func testRPC_mTLS_peerCertificateChain() async throws {
+    // Create a new certificate chain that has 4 certificate/key pairs: root, intermediate, client, server
+    let certificateChain = try CertificateChain()
+    let expectedCertificateChain = [certificateChain.client.certificate]
+    let filePaths = try certificateChain.writeToTemp()
+
+    // Client and server configurations.
+    let clientConfig = self.makeMTLSClientConfig(
+      certificatePath: filePaths.clientCert,
+      keyPath: filePaths.clientKey,
+      trustRootsPath: filePaths.trustRoots,
+      serverHostname: CertificateChain.serverName
+    )
+    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([certificateChain.client.certificate] == presentedCertificates)
+      // "Verify" the chain and set the certificate.
+      promise.succeed(
+        .certificateVerified(VerificationMetadata(ValidatedCertificateChain(certificates)))
+      )
+    }
+
+    // Run the test. The interceptor checks that we can query the expected certificate chain.
+    try await self.withClientAndServer(
+      clientConfig: clientConfig,
+      serverConfig: serverConfig,
+      interceptors: [ValidatedCertificateChainInterceptor(expectedCertificateChain)]
+    ) { control in
+      await #expect(throws: Never.self) {
+        try await self.executeUnaryRPC(control: control)
+      }
+    }
+  }
+
   @Test(
     "Error is surfaced when client fails server verification",
     arguments: TransportKind.clientsWithTLS,