Browse Source

Add API for custom private keys (#102)

Motivation:

NIOSSL has support for custom private keys which may be comre from some
form of high-security storage. This API isn't currently available to
users.

Modifications:

- Extend private key source with a transport specific backing and add
API to initialize it with a custom private key

Result:

NIOSSLs custom private keys can be used.
George Barnett 8 months ago
parent
commit
c75f440435

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

@@ -0,0 +1,28 @@
+/*
+ * 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.
+ */
+
+extension TLSConfig.PrivateKeySource {
+  /// Marker protocol for transport specific private key sources.
+  ///
+  /// `TLSConfig.PrivateKeySource` is from the core module which means it can't take a NIOSSL
+  /// or NIOTransportServices dependency. In order to support more sources this transport-specific
+  /// protocol is provided as non-public API.
+  package protocol TransportSpecific: Sendable {}
+
+  package static func transportSpecific(_ source: any TransportSpecific) -> Self {
+    Self(wrapped: .transportSpecific(source))
+  }
+}

+ 1 - 0
Sources/GRPCNIOTransportCore/TLSConfig.swift

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

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

@@ -14,6 +14,8 @@
  * limitations under the License.
  */
 
+public import NIOSSL
+
 extension HTTP2ServerTransport.Posix {
   /// The security configuration for this connection.
   public struct TransportSecurity: Sendable {
@@ -308,3 +310,16 @@ extension HTTP2ClientTransport.Posix.TransportSecurity {
     }
   }
 }
+
+extension TLSConfig.PrivateKeySource {
+  /// Creates a key source from a `NIOSSLCustomPrivateKey`.
+  ///
+  /// This private key source is only applicable to the NIOPosix based transports. Using one
+  /// with a NIOTransportServices based transport is a programmer error.
+  ///
+  /// - Parameter key: The custom private key.
+  /// - Returns: A private key source wrapping the custom private key.
+  public static func customPrivateKey(_ key: any (NIOSSLCustomPrivateKey & Hashable)) -> Self {
+    .nioSSLSpecific(.customPrivateKey(key))
+  }
+}

+ 50 - 14
Sources/GRPCNIOTransportHTTP2Posix/NIOSSL+GRPC.swift

@@ -14,7 +14,8 @@
  * limitations under the License.
  */
 
-import NIOSSL
+internal import GRPCCore
+internal import NIOSSL
 
 extension NIOSSLSerializationFormats {
   fileprivate init(_ format: TLSConfig.SerializationFormat) {
@@ -65,21 +66,59 @@ extension Sequence<TLSConfig.CertificateSource> {
   }
 }
 
+extension TLSConfig.PrivateKeySource {
+  enum _NIOSSLPrivateKeySource: TransportSpecific {
+    case customPrivateKey(any (NIOSSLCustomPrivateKey & Hashable))
+    case privateKey(NIOSSLPrivateKeySource)
+  }
+
+  static func nioSSLSpecific(_ source: _NIOSSLPrivateKeySource) -> Self {
+    .transportSpecific(source)
+  }
+}
+
 extension NIOSSLPrivateKey {
-  fileprivate convenience init(
-    privateKey source: TLSConfig.PrivateKeySource
-  ) throws {
+  fileprivate static func makePrivateKey(
+    from source: TLSConfig.PrivateKeySource
+  ) throws -> NIOSSLPrivateKey {
     switch source.wrapped {
     case .file(let path, let serializationFormat):
-      try self.init(
+      return try self.init(
         file: path,
         format: NIOSSLSerializationFormats(serializationFormat)
       )
+
     case .bytes(let bytes, let serializationFormat):
-      try self.init(
+      return try self.init(
         bytes: bytes,
         format: NIOSSLSerializationFormats(serializationFormat)
       )
+
+    case .transportSpecific(let extraSource):
+      guard let source = extraSource as? TLSConfig.PrivateKeySource._NIOSSLPrivateKeySource else {
+        fatalError("Invalid private key source of type \(type(of: extraSource))")
+      }
+
+      switch source {
+      case .customPrivateKey(let privateKey):
+        return self.init(customPrivateKey: privateKey)
+
+      case .privateKey(.privateKey(let key)):
+        return key
+
+      case .privateKey(.file(let path)):
+        switch path.split(separator: ".").last {
+        case "pem":
+          return try NIOSSLPrivateKey(file: path, format: .pem)
+        case "der", "key":
+          return try NIOSSLPrivateKey(file: path, format: .der)
+        default:
+          throw RPCError(
+            code: .invalidArgument,
+            message: "Couldn't load private key from \(path)."
+          )
+        }
+      }
     }
   }
 }
@@ -128,16 +167,15 @@ extension CertificateVerification {
 extension TLSConfiguration {
   package init(_ tlsConfig: HTTP2ServerTransport.Posix.TransportSecurity.TLS) throws {
     let certificateChain = try tlsConfig.certificateChain.sslCertificateSources()
-    let privateKey = try NIOSSLPrivateKey(privateKey: tlsConfig.privateKey)
+    let privateKey = try NIOSSLPrivateKey.makePrivateKey(from: tlsConfig.privateKey)
 
     self = TLSConfiguration.makeServerConfiguration(
       certificateChain: certificateChain,
       privateKey: .privateKey(privateKey)
     )
+
     self.minimumTLSVersion = .tlsv12
-    self.certificateVerification = CertificateVerification(
-      tlsConfig.clientCertificateVerification
-    )
+    self.certificateVerification = CertificateVerification(tlsConfig.clientCertificateVerification)
     self.trustRoots = try NIOSSLTrustRoots(tlsConfig.trustRoots)
     self.applicationProtocols = ["grpc-exp", "h2"]
   }
@@ -147,14 +185,12 @@ extension TLSConfiguration {
     self.certificateChain = try tlsConfig.certificateChain.sslCertificateSources()
 
     if let privateKey = tlsConfig.privateKey {
-      let privateKeySource = try NIOSSLPrivateKey(privateKey: privateKey)
+      let privateKeySource = try NIOSSLPrivateKey.makePrivateKey(from: privateKey)
       self.privateKey = .privateKey(privateKeySource)
     }
 
     self.minimumTLSVersion = .tlsv12
-    self.certificateVerification = CertificateVerification(
-      tlsConfig.serverCertificateVerification
-    )
+    self.certificateVerification = CertificateVerification(tlsConfig.serverCertificateVerification)
     self.trustRoots = try NIOSSLTrustRoots(tlsConfig.trustRoots)
     self.applicationProtocols = ["grpc-exp", "h2"]
   }

+ 98 - 0
Tests/GRPCNIOTransportHTTP2Tests/TLSConfigurationTests.swift

@@ -0,0 +1,98 @@
+/*
+ * 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 GRPCNIOTransportHTTP2
+import NIOCore
+import NIOSSL
+import Testing
+
+struct TLSConfigurationTests {
+  struct NoOpCustomPrivateKey: NIOSSLCustomPrivateKey, Hashable {
+    var signatureAlgorithms: [SignatureAlgorithm] { [] }
+
+    func sign(
+      channel: any Channel,
+      algorithm: SignatureAlgorithm,
+      data: ByteBuffer
+    ) -> EventLoopFuture<ByteBuffer> {
+      channel.eventLoop.makeSucceededFuture(ByteBuffer())
+    }
+
+    func decrypt(channel: any Channel, data: ByteBuffer) -> EventLoopFuture<ByteBuffer> {
+      channel.eventLoop.makeSucceededFuture(ByteBuffer())
+    }
+  }
+
+  @Test("Client custom private key")
+  func clientTLSCustomPrivateKey() throws {
+    let custom = NoOpCustomPrivateKey()
+    let config = HTTP2ClientTransport.Posix.TransportSecurity.tls {
+      $0.privateKey = .customPrivateKey(custom)
+    }
+
+    let tls = try #require(config.tls)
+    let tlsConfig = try TLSConfiguration(tls)
+    let privateKey = try #require(tlsConfig.privateKey?.privateKey)
+    #expect(privateKey == NIOSSLPrivateKey(customPrivateKey: custom))
+  }
+
+  @Test("Server custom private key")
+  func serverTLSCustomPrivateKey() throws {
+    let custom = NoOpCustomPrivateKey()
+    let config = HTTP2ServerTransport.Posix.TransportSecurity.tls(
+      certificateChain: [],
+      privateKey: .customPrivateKey(custom)
+    )
+
+    let tls = try #require(config.tls)
+    let tlsConfig = try TLSConfiguration(tls)
+    let privateKey = try #require(tlsConfig.privateKey?.privateKey)
+    #expect(privateKey == NIOSSLPrivateKey(customPrivateKey: custom))
+  }
+}
+
+extension HTTP2ClientTransport.Posix.TransportSecurity {
+  var tls: TLS? {
+    switch self.wrapped {
+    case .tls(let tls):
+      return tls
+    case .plaintext:
+      return nil
+    }
+  }
+}
+
+extension HTTP2ServerTransport.Posix.TransportSecurity {
+  var tls: TLS? {
+    switch self.wrapped {
+    case .tls(let tls):
+      return tls
+    case .plaintext:
+      return nil
+    }
+  }
+}
+
+extension NIOSSLPrivateKeySource {
+  var privateKey: NIOSSLPrivateKey? {
+    switch self {
+    case .privateKey(let key):
+      return key
+    case .file:
+      return nil
+    }
+  }
+}