diff --git a/Sources/GRPCNIOTransportHTTP2Posix/NIOSSL+GRPC.swift b/Sources/GRPCNIOTransportHTTP2Posix/NIOSSL+GRPC.swift index 7b6c892..d01b28d 100644 --- a/Sources/GRPCNIOTransportHTTP2Posix/NIOSSL+GRPC.swift +++ b/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, diff --git a/Tests/GRPCNIOTransportHTTP2Tests/HTTP2TransportTLSEnabledTests.swift b/Tests/GRPCNIOTransportHTTP2Tests/HTTP2TransportTLSEnabledTests.swift index 67a499b..ddc37e7 100644 --- a/Tests/GRPCNIOTransportHTTP2Tests/HTTP2TransportTLSEnabledTests.swift +++ b/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( request: GRPCCore.StreamingServerRequest, context: GRPCCore.ServerContext, - next: @Sendable (GRPCCore.StreamingServerRequest, GRPCCore.ServerContext) async throws + next: + @Sendable (GRPCCore.StreamingServerRequest, GRPCCore.ServerContext) async throws -> GRPCCore.StreamingServerResponse ) async throws -> GRPCCore.StreamingServerResponse 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, diff --git a/Tests/GRPCNIOTransportHTTP2Tests/Test Utilities/CertificateChain.swift b/Tests/GRPCNIOTransportHTTP2Tests/Test Utilities/CertificateChain.swift new file mode 100644 index 0000000..b8c6d28 --- /dev/null +++ b/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) + } +}