Explorar o código

Load multiple certificates from a single trust root file (#115)

### Motivation

When passing a PEM file with multiple certificates as trust roots, only
the first one is loaded.

### Modifications

Instead of creating a NIOSSLCertificate directly, use a function that
loads multiple certificates from a PEM file. Add a test case that
creates a certificate chain in a temporary directory to verify the
expected behavior.

### Result

Multiple certificates can be loaded from a single PEM file.
Raphael hai 5 meses
pai
achega
46a4128ec6

+ 18 - 13
Sources/GRPCNIOTransportHTTP2Posix/NIOSSL+GRPC.swift

@@ -1,5 +1,5 @@
 /*
- * Copyright 2024, gRPC Authors All rights reserved.
+ * 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.
@@ -141,19 +141,24 @@ extension NIOSSLTrustRoots {
   fileprivate init(_ trustRoots: TLSConfig.TrustRootsSource) throws {
     switch trustRoots.wrapped {
     case .certificates(let certificateSources):
-      let certificates = try certificateSources.map { source in
+      var certificates: [NIOSSLCertificate] = []
+      for source in certificateSources {
         switch source.wrapped {
         case .bytes(let bytes, let serializationFormat):
-          return try NIOSSLCertificate(
-            bytes: bytes,
-            format: NIOSSLSerializationFormats(serializationFormat)
-          )
+          switch serializationFormat.wrapped {
+          case .pem:
+            certificates.append(contentsOf: try NIOSSLCertificate.fromPEMBytes(bytes))
+          case .der:
+            certificates.append(try NIOSSLCertificate(bytes: bytes, format: .der))
+          }
 
         case .file(let path, let serializationFormat):
-          return try NIOSSLCertificate(
-            file: path,
-            format: NIOSSLSerializationFormats(serializationFormat)
-          )
+          switch serializationFormat.wrapped {
+          case .pem:
+            certificates.append(contentsOf: try NIOSSLCertificate.fromPEMFile(path))
+          case .der:
+            certificates.append(try NIOSSLCertificate(file: path, format: .der))
+          }
 
         case .transportSpecific(let specific):
           guard let source = specific.wrapped as? NIOSSLCertificateSource else {
@@ -162,14 +167,14 @@ extension NIOSSLTrustRoots {
 
           switch source {
           case .certificate(let certificate):
-            return certificate
+            certificates.append(certificate)
 
           case .file(let path):
             switch path.split(separator: ".").last {
             case "pem":
-              return try NIOSSLCertificate(file: path, format: .pem)
+              certificates.append(contentsOf: try NIOSSLCertificate.fromPEMFile(path))
             case "der":
-              return try NIOSSLCertificate(file: path, format: .der)
+              certificates.append(try NIOSSLCertificate(file: path, format: .der))
             default:
               throw RPCError(
                 code: .invalidArgument,

+ 79 - 2
Tests/GRPCNIOTransportHTTP2Tests/HTTP2TransportTLSEnabledTests.swift

@@ -1,5 +1,5 @@
 /*
- * Copyright 2024, gRPC Authors All rights reserved.
+ * 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.
@@ -69,7 +69,8 @@ struct HTTP2TransportTLSEnabledTests {
     func intercept<Input, Output>(
       request: GRPCCore.StreamingServerRequest<Input>,
       context: GRPCCore.ServerContext,
-      next: @Sendable (GRPCCore.StreamingServerRequest<Input>, GRPCCore.ServerContext) async throws
+      next:
+        @Sendable (GRPCCore.StreamingServerRequest<Input>, GRPCCore.ServerContext) async throws
         -> GRPCCore.StreamingServerResponse<Output>
     ) async throws -> GRPCCore.StreamingServerResponse<Output>
     where Input: Sendable, Output: Sendable {
@@ -146,6 +147,44 @@ struct HTTP2TransportTLSEnabledTests {
     }
   }
 
+  @Test(
+    "When using mTLS with PEM files, both client and server verify each others' certificates"
+  )
+  @available(gRPCSwiftNIOTransport 2.0, *)
+  func testRPC_mTLS_posixFileBasedCertificates_OK() async throws {
+    // Create a new certificate chain that has 4 certificate/key pairs: root, intermediate, client, server
+    let certificateChain = try CertificateChain()
+    // Tag our certificate files with the function name
+    let filePaths = try certificateChain.writeToTemp()
+    // Check that the files
+    #expect(FileManager.default.fileExists(atPath: filePaths.clientCert))
+    #expect(FileManager.default.fileExists(atPath: filePaths.clientKey))
+    #expect(FileManager.default.fileExists(atPath: filePaths.serverCert))
+    #expect(FileManager.default.fileExists(atPath: filePaths.serverKey))
+    #expect(FileManager.default.fileExists(atPath: filePaths.trustRoots))
+    // Create configurations
+    let clientConfig = self.makeMTLSClientConfig(
+      certificatePath: filePaths.clientCert,
+      keyPath: filePaths.clientKey,
+      trustRootsPath: filePaths.trustRoots,
+      serverHostname: CertificateChain.serverName
+    )
+    let serverConfig = self.makeMTLSServerConfig(
+      certificatePath: filePaths.serverCert,
+      keyPath: filePaths.serverKey,
+      trustRootsPath: filePaths.trustRoots
+    )
+    // 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(
     "Error is surfaced when client fails server verification",
     arguments: TransportKind.clientsWithTLS,
@@ -471,6 +510,26 @@ struct HTTP2TransportTLSEnabledTests {
     }
   }
 
+  @available(gRPCSwiftNIOTransport 2.0, *)
+  private func makeMTLSClientConfig(
+    certificatePath: String,
+    keyPath: String,
+    trustRootsPath: String,
+    serverHostname: String?
+  ) -> ClientConfig {
+    var config = self.makeDefaultPlaintextPosixClientConfig()
+    config.security = .mTLS(
+      certificateChain: [.file(path: certificatePath, format: .pem)],
+      privateKey: .file(path: keyPath, format: .pem)
+    ) {
+      $0.trustRoots = .certificates([
+        .file(path: trustRootsPath, format: .pem)
+      ])
+    }
+    config.transport.http2.authority = serverHostname
+    return .posix(config)
+  }
+
   @available(gRPCSwiftNIOTransport 2.0, *)
   private func makeDefaultPlaintextPosixServerConfig() -> ServerConfig.Posix {
     ServerConfig.Posix(security: .plaintext, transport: .defaults)
@@ -558,6 +617,24 @@ struct HTTP2TransportTLSEnabledTests {
     }
   }
 
+  @available(gRPCSwiftNIOTransport 2.0, *)
+  private func makeMTLSServerConfig(
+    certificatePath: String,
+    keyPath: String,
+    trustRootsPath: String
+  ) -> ServerConfig {
+    var config = self.makeDefaultPlaintextPosixServerConfig()
+    config.security = .mTLS(
+      certificateChain: [.file(path: certificatePath, format: .pem)],
+      privateKey: .file(path: keyPath, format: .pem)
+    ) {
+      $0.trustRoots = .certificates([
+        .file(path: trustRootsPath, format: .pem)
+      ])
+    }
+    return .posix(config)
+  }
+
   @available(gRPCSwiftNIOTransport 2.0, *)
   func withClientAndServer(
     clientConfig: ClientConfig,

+ 315 - 0
Tests/GRPCNIOTransportHTTP2Tests/Test Utilities/CertificateChain.swift

@@ -0,0 +1,315 @@
+/*
+ * 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.
+ */
+
+import Crypto
+import Foundation
+import SwiftASN1
+import X509
+
+/// Create a certificate chain with a root and intermediate certificate stored in a trust roots file and
+/// key / certificate pairs for a client and a server. These can be used to establish mTLS connections
+/// with local trust.
+///
+/// Usage example:
+/// ```
+/// // Create a new certificate chain
+/// let certificateChain = try CertificateChain()
+/// // Tag our certificate files with the function name
+/// let filePaths = try certificateChain.writeToTemp(fileTag: #function)
+/// // Access the file paths of the certificate files.
+/// let clientCertPath = filePaths.clientCert
+/// ...
+/// ```
+struct CertificateChain {
+  /// Each node in the chain has a certificate and a key
+  struct CertificateKeyPair {
+    let certificate: Certificate
+    let key: Certificate.PrivateKey
+  }
+
+  /// Leaf certificates can authenticate either a client or a server
+  enum Authenticating {
+    case client
+    case server
+  }
+
+  /// Writing the files to disk returns the paths to all written files
+  struct FilePaths {
+    var clientCert: String
+    var clientKey: String
+    var serverCert: String
+    var serverKey: String
+    var trustRoots: String
+  }
+
+  /// The domains names for the leaf certificates
+  static let serverName = "my.server"
+  static let clientName = "my.client"
+
+  /// Our certificate chain
+  let root: CertificateKeyPair
+  let intermediate: CertificateKeyPair
+  let server: CertificateKeyPair
+  let client: CertificateKeyPair
+
+  /// On initialization create a chain of certificates: root signes intermediate, intermediate signs both leaf certificates
+  init() throws {
+    let root = try Self.makeRootCertificate(commonName: "root")
+    let intermediate = try Self.makeIntermediateCertificate(
+      commonName: "intermediate",
+      signedBy: root
+    )
+
+    let server = try Self.makeLeafCertificate(
+      commonName: "server",
+      domainName: CertificateChain.serverName,
+      authenticating: .server,
+      signedBy: intermediate
+    )
+    let client = try Self.makeLeafCertificate(
+      commonName: "client",
+      domainName: CertificateChain.clientName,
+      authenticating: .client,
+      signedBy: intermediate
+    )
+
+    self.root = root
+    self.intermediate = intermediate
+    self.server = server
+    self.client = client
+  }
+
+  /// Create a new root certificate.
+  ///
+  /// - Parameter commonName: CN of the certificate
+  /// - Returns: A certificate and a private key.
+  private static func makeRootCertificate(commonName cn: String) throws -> CertificateKeyPair {
+    let privateKey = P256.Signing.PrivateKey()
+    let key = Certificate.PrivateKey(privateKey)
+
+    let subjectName = try DistinguishedName {
+      CommonName(cn)
+    }
+    let issuerName = subjectName
+
+    let now = Date()
+
+    let extensions = try Certificate.Extensions {
+      Critical(
+        BasicConstraints.isCertificateAuthority(maxPathLength: nil)
+      )
+      Critical(
+        KeyUsage(keyCertSign: true)
+      )
+    }
+
+    let certificate = try Certificate(
+      version: .v3,
+      serialNumber: Certificate.SerialNumber(),
+      publicKey: key.publicKey,
+      notValidBefore: now.addingTimeInterval(-1),
+      notValidAfter: now.addingTimeInterval(60 * 60 * 24 * 365 * 10),  // 10 years
+      issuer: issuerName,
+      subject: subjectName,
+      signatureAlgorithm: .ecdsaWithSHA256,
+      extensions: extensions,
+      issuerPrivateKey: key
+    )
+
+    return CertificateKeyPair(certificate: certificate, key: key)
+  }
+
+  /// Create a new intermediate certificate.
+  ///
+  /// - Parameters:
+  ///   - commonName: CN for the certificate
+  ///   - signedBy: Certificate that signs this
+  /// - Returns: A certificate and a private key.
+  private static func makeIntermediateCertificate(
+    commonName cn: String,
+    signedBy issuer: CertificateKeyPair
+  ) throws -> CertificateKeyPair {
+
+    // Generate a new private key for the intermediate certificate
+    let privateKey = P256.Signing.PrivateKey()
+    let key = Certificate.PrivateKey(privateKey)
+
+    // Create subject name for the intermediate certificate
+    let subjectName = try DistinguishedName {
+      CommonName(cn)
+    }
+
+    // Parse the root certificate to get the issuer information
+    let issuerCert = issuer.certificate
+    let issuerName = issuerCert.subject
+
+    // Parse the root certificate's private key for signing
+    let issuerKey = issuer.key
+
+    let now = Date()
+
+    // Configure extensions for intermediate CA
+    let extensions = try Certificate.Extensions {
+      Critical(
+        BasicConstraints.isCertificateAuthority(
+          maxPathLength: nil
+        )
+      )
+
+      Critical(
+        KeyUsage(keyCertSign: true, cRLSign: true)
+      )
+
+      // Add Authority Key Identifier linking to the root certificate
+      try AuthorityKeyIdentifier(
+        keyIdentifier: issuerCert.extensions.subjectKeyIdentifier?
+          .keyIdentifier
+      )
+    }
+
+    // Create the intermediate certificate
+    let certificate = try Certificate(
+      version: .v3,
+      serialNumber: Certificate.SerialNumber(),
+      publicKey: key.publicKey,
+      notValidBefore: now.addingTimeInterval(-1),
+      notValidAfter: now.addingTimeInterval(60 * 60 * 24 * 365),  // 1 year
+      issuer: issuerName,
+      subject: subjectName,
+      signatureAlgorithm: .ecdsaWithSHA256,
+      extensions: extensions,
+      issuerPrivateKey: issuerKey
+    )
+
+    return CertificateKeyPair(
+      certificate: certificate,
+      key: key
+    )
+  }
+
+  /// Create a new leaf certificate.
+  ///
+  /// - Parameters:
+  ///   - commonName: CN for the certificate
+  ///   - domainName: Domain name added as a SAN to the cert
+  ///   - authenticating: Whether the certificate authenticates a client or a server
+  ///   - signedBy: Certificate that signs this
+  /// - Returns: A certificate and a private key.
+  private static func makeLeafCertificate(
+    commonName cn: String,
+    domainName: String,
+    authenticating side: Authenticating,
+    signedBy issuer: CertificateKeyPair
+  ) throws -> CertificateKeyPair {
+
+    // Generate a new private key for the Leaf certificate
+    let privateKey = P256.Signing.PrivateKey()
+    let key = Certificate.PrivateKey(privateKey)
+
+    // Create subject name for the Leaf certificate
+    let subjectName = try DistinguishedName {
+      CommonName(cn)
+    }
+
+    // Parse the root certificate to get the issuer information
+    let issuerCert = issuer.certificate
+    let issuerName = issuerCert.subject
+
+    // Parse the root certificate's private key for signing
+    let issuerKey = issuer.key
+
+    let now = Date()
+
+    // Configure extensions for Leaf CA
+    let extensions = try Certificate.Extensions {
+      BasicConstraints.notCertificateAuthority
+
+      try ExtendedKeyUsage(
+        side == .server ? [.serverAuth] : [.clientAuth]
+      )
+
+      SubjectAlternativeNames([.dnsName(domainName)])
+    }
+
+    // Create the Leaf certificate
+    let certificate = try Certificate(
+      version: .v3,
+      serialNumber: Certificate.SerialNumber(),
+      publicKey: key.publicKey,
+      notValidBefore: now.addingTimeInterval(-1),
+      notValidAfter: now.addingTimeInterval(60 * 60 * 24 * 90),  // 90 days
+      issuer: issuerName,
+      subject: subjectName,
+      signatureAlgorithm: .ecdsaWithSHA256,
+      extensions: extensions,
+      issuerPrivateKey: issuerKey
+    )
+
+    return CertificateKeyPair(
+      certificate: certificate,
+      key: key
+    )
+  }
+
+  /// Write the certificate chain to a temporary directory.
+  ///
+  /// - Parameters:
+  ///   - fileTag: A prefix added to all certificates files
+  /// - Returns: A struct that contains paths of the written file
+  public func writeToTemp(fileTag: String = #function) throws -> FilePaths {
+    let fm = FileManager.default
+    let directory = fm.temporaryDirectory
+
+    // Store file paths
+    let trustRootsURL = directory.appendingPathComponent("\(fileTag).ca-chain.cert.pem")
+    let clientCertURL = directory.appendingPathComponent("\(fileTag).client.cert.pem")
+    let clientKeyURL = directory.appendingPathComponent("\(fileTag).client.key.pem")
+    let serverCertURL = directory.appendingPathComponent("\(fileTag).server.cert.pem")
+    let serverKeyURL = directory.appendingPathComponent("\(fileTag).server.key.pem")
+
+    // Write chain: certificates of the root and intermediate in one file
+    let rootPEM = try self.root.certificate.serializeAsPEM().pemString
+    let intermediatePEM = try self.intermediate.certificate.serializeAsPEM().pemString
+    try intermediatePEM.appending("\n").appending(rootPEM).write(
+      to: trustRootsURL,
+      atomically: true,
+      encoding: .utf8
+    )
+
+    // Write leaf certificates and keys
+    try self.client.writeKeyPair(certPath: clientCertURL, keyPath: clientKeyURL)
+    try self.server.writeKeyPair(certPath: serverCertURL, keyPath: serverKeyURL)
+
+    return FilePaths(
+      clientCert: clientCertURL.path(),
+      clientKey: clientKeyURL.path(),
+      serverCert: serverCertURL.path(),
+      serverKey: serverKeyURL.path(),
+      trustRoots: trustRootsURL.path()
+    )
+  }
+}
+
+extension CertificateChain.CertificateKeyPair {
+  fileprivate func writeKeyPair(certPath: URL, keyPath: URL) throws {
+    try self.certificate.serializeAsPEM().pemString.write(
+      to: certPath,
+      atomically: true,
+      encoding: .utf8
+    )
+    try self.key.serializeAsPEM().pemString.write(to: keyPath, atomically: true, encoding: .utf8)
+  }
+}