diff --git a/grpc-server-spring-boot-starter/build.gradle b/grpc-server-spring-boot-starter/build.gradle index c3f39af1e..f969922ae 100644 --- a/grpc-server-spring-boot-starter/build.gradle +++ b/grpc-server-spring-boot-starter/build.gradle @@ -33,4 +33,6 @@ dependencies { testImplementation 'io.grpc:grpc-testing' testImplementation('org.springframework.boot:spring-boot-starter-test') + testImplementation 'org.bouncycastle:bcpkix-jdk18on:1.77' + } diff --git a/grpc-server-spring-boot-starter/src/main/java/net/devh/boot/grpc/server/security/authentication/SSLContextGrpcAuthenticationReader.java b/grpc-server-spring-boot-starter/src/main/java/net/devh/boot/grpc/server/security/authentication/SSLContextGrpcAuthenticationReader.java index 0842218fe..ff825847e 100644 --- a/grpc-server-spring-boot-starter/src/main/java/net/devh/boot/grpc/server/security/authentication/SSLContextGrpcAuthenticationReader.java +++ b/grpc-server-spring-boot-starter/src/main/java/net/devh/boot/grpc/server/security/authentication/SSLContextGrpcAuthenticationReader.java @@ -53,7 +53,7 @@ public Authentication readAuthentication(final ServerCall call, final Meta log.trace("Peer not verified via certificate", e); return null; } - return fromCertificate(certs[certs.length - 1]); + return fromCertificate(certs[0]); } /** diff --git a/grpc-server-spring-boot-starter/src/test/java/net/devh/boot/grpc/server/security/authentication/CertificateHelper.java b/grpc-server-spring-boot-starter/src/test/java/net/devh/boot/grpc/server/security/authentication/CertificateHelper.java new file mode 100644 index 000000000..ab7e40492 --- /dev/null +++ b/grpc-server-spring-boot-starter/src/test/java/net/devh/boot/grpc/server/security/authentication/CertificateHelper.java @@ -0,0 +1,170 @@ +/* + * Copyright (c) 2016-2024 The gRPC-Spring Authors + * + * 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. + */ + +package net.devh.boot.grpc.server.security.authentication; + +import java.math.BigInteger; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.Provider; +import java.security.PublicKey; +import java.security.SecureRandom; +import java.security.Security; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.time.ZonedDateTime; +import java.util.Date; + +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.ExtendedKeyUsage; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.GeneralName; +import org.bouncycastle.asn1.x509.GeneralNamesBuilder; +import org.bouncycastle.asn1.x509.KeyPurposeId; +import org.bouncycastle.asn1.x509.KeyUsage; +import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo; +import org.bouncycastle.cert.CertIOException; +import org.bouncycastle.cert.X509v3CertificateBuilder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509CertificateHolder; +import org.bouncycastle.cert.jcajce.JcaX509ExtensionUtils; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.OperatorCreationException; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; + +public class CertificateHelper { + + static final Provider PROVIDER = new BouncyCastleProvider(); + static { + Security.addProvider(PROVIDER); + } + + final SecureRandom secureRandom = new SecureRandom(); + + CertificateAndKeys rootCertificate(String subject) + throws NoSuchAlgorithmException, CertIOException, CertificateException, OperatorCreationException { + var keyPair = keyPair(); + var subjectName = new X500Name(subject); + var certBuilder = buildCertificate(subjectName, subjectName, keyPair.getPublic()); + var extensionUtils = new JcaX509ExtensionUtils(); + + var subjectKeyIdentifier = extensionUtils.createSubjectKeyIdentifier(keyPair.getPublic()); + certBuilder.addExtension(Extension.subjectKeyIdentifier, false, subjectKeyIdentifier); + + var keyUsage = new KeyUsage(KeyUsage.keyCertSign | KeyUsage.cRLSign); + certBuilder.addExtension(Extension.keyUsage, true, keyUsage); + + var constraints = new BasicConstraints(0); + certBuilder.addExtension(Extension.basicConstraints, true, constraints); + + var certificate = signCertificate(certBuilder, keyPair.getPrivate()); + return new CertificateAndKeys(certificate, keyPair); + } + + CertificateAndKeys intermediateCertificate(String subject, CertificateAndKeys issuer) + throws NoSuchAlgorithmException, CertIOException, CertificateException, OperatorCreationException { + var keyPair = keyPair(); + var issuerCertificate = issuer.certificate(); + var certBuilder = buildCertificate(new JcaX509CertificateHolder(issuerCertificate).getSubject(), + new X500Name(subject), keyPair.getPublic()); + var extensionUtils = new JcaX509ExtensionUtils(); + + var subjectKeyIdentifier = extensionUtils.createSubjectKeyIdentifier(keyPair.getPublic()); + certBuilder.addExtension(Extension.subjectKeyIdentifier, false, subjectKeyIdentifier); + + var authorityKeyIdentifier = extensionUtils.createAuthorityKeyIdentifier(issuerCertificate); + certBuilder.addExtension(Extension.authorityKeyIdentifier, false, authorityKeyIdentifier); + + var keyUsage = new KeyUsage(KeyUsage.keyCertSign | KeyUsage.cRLSign); + certBuilder.addExtension(Extension.keyUsage, true, keyUsage); + + var constraints = new BasicConstraints(0); + certBuilder.addExtension(Extension.basicConstraints, true, constraints); + + var certificate = signCertificate(certBuilder, issuer.keyPair().getPrivate()); + return new CertificateAndKeys(certificate, keyPair); + } + + CertificateAndKeys leafCertificate(String subject, CertificateAndKeys issuer) + throws NoSuchAlgorithmException, CertIOException, CertificateException, OperatorCreationException { + var keyPair = keyPair(); + var issuerCertificate = issuer.certificate(); + var certBuilder = buildCertificate(new JcaX509CertificateHolder(issuerCertificate).getSubject(), + new X500Name(subject), keyPair.getPublic()); + var extensionUtils = new JcaX509ExtensionUtils(); + + var subjectKeyIdentifier = extensionUtils.createSubjectKeyIdentifier(keyPair.getPublic()); + certBuilder.addExtension(Extension.subjectKeyIdentifier, false, subjectKeyIdentifier); + + var authorityKeyIdentifier = extensionUtils.createAuthorityKeyIdentifier(issuerCertificate); + certBuilder.addExtension(Extension.authorityKeyIdentifier, false, authorityKeyIdentifier); + + var keyUsage = new KeyUsage(KeyUsage.digitalSignature | KeyUsage.keyEncipherment); + certBuilder.addExtension(Extension.keyUsage, true, keyUsage); + + var extendedKeyUsage = + new ExtendedKeyUsage(new KeyPurposeId[] {KeyPurposeId.id_kp_serverAuth, KeyPurposeId.id_kp_clientAuth}); + certBuilder.addExtension(Extension.extendedKeyUsage, false, extendedKeyUsage); + + certBuilder.addExtension(Extension.subjectAlternativeName, false, new GeneralNamesBuilder() + .addName(new GeneralName(GeneralName.dNSName, "localhost")) + .addName(new GeneralName(GeneralName.iPAddress, "127.0.0.1")) + .build()); + + var certificate = signCertificate(certBuilder, issuer.keyPair().getPrivate()); + return new CertificateAndKeys(certificate, keyPair); + } + + private X509v3CertificateBuilder buildCertificate(X500Name issuer, X500Name subject, PublicKey publicKey) { + var publicKeyInfo = SubjectPublicKeyInfo.getInstance(publicKey.getEncoded()); + var now = ZonedDateTime.now(); + return new X509v3CertificateBuilder( + issuer, + new BigInteger(160, secureRandom), + Date.from(now.toInstant()), + Date.from(now.plusDays(1).toInstant()), + subject, + publicKeyInfo); + } + + private X509Certificate signCertificate(X509v3CertificateBuilder certificateBuilder, PrivateKey privateKey) + throws OperatorCreationException, CertificateException { + var signer = contentSigner(privateKey); + var certificateHolder = certificateBuilder.build(signer); + return new JcaX509CertificateConverter() + .setProvider(PROVIDER) + .getCertificate(certificateHolder); + } + + private KeyPair keyPair() throws NoSuchAlgorithmException { + var keyPairGenerator = KeyPairGenerator.getInstance("RSA", PROVIDER); + keyPairGenerator.initialize(2048); + return keyPairGenerator.generateKeyPair(); + } + + private ContentSigner contentSigner(PrivateKey privateKey) throws OperatorCreationException { + return new JcaContentSignerBuilder("SHA256WithRSA") + .setProvider(PROVIDER) + .build(privateKey); + } + + record CertificateAndKeys(X509Certificate certificate, KeyPair keyPair) {} + +} diff --git a/grpc-server-spring-boot-starter/src/test/java/net/devh/boot/grpc/server/security/authentication/SSLContextGrpcAuthenticationReaderTest.java b/grpc-server-spring-boot-starter/src/test/java/net/devh/boot/grpc/server/security/authentication/SSLContextGrpcAuthenticationReaderTest.java new file mode 100644 index 000000000..566381187 --- /dev/null +++ b/grpc-server-spring-boot-starter/src/test/java/net/devh/boot/grpc/server/security/authentication/SSLContextGrpcAuthenticationReaderTest.java @@ -0,0 +1,174 @@ +/* + * Copyright (c) 2016-2024 The gRPC-Spring Authors + * + * 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. + */ + +package net.devh.boot.grpc.server.security.authentication; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.security.cert.X509Certificate; +import java.util.concurrent.CompletableFuture; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.security.core.Authentication; + +import io.grpc.Grpc; +import io.grpc.ManagedChannel; +import io.grpc.Metadata; +import io.grpc.ServerCall; +import io.grpc.ServerCall.Listener; +import io.grpc.ServerCallHandler; +import io.grpc.ServerInterceptor; +import io.grpc.TlsChannelCredentials; +import io.grpc.TlsServerCredentials; +import io.grpc.TlsServerCredentials.ClientAuth; +import io.grpc.health.v1.HealthCheckRequest; +import io.grpc.health.v1.HealthCheckResponse; +import io.grpc.health.v1.HealthGrpc; +import io.grpc.protobuf.services.HealthStatusManager; +import io.grpc.stub.StreamObserver; +import io.grpc.util.AdvancedTlsX509KeyManager; +import io.grpc.util.AdvancedTlsX509TrustManager; +import net.devh.boot.grpc.server.security.authentication.CertificateHelper.CertificateAndKeys; + +class SSLContextGrpcAuthenticationReaderTest { + + final CertificateHelper certificateHelper = new CertificateHelper(); + CertificateAndKeys root; + CertificateAndKeys intermediate; + CertificateAndKeys server; + CertificateAndKeys client; + CertificateAndKeys clientWithIntermediate; + + @BeforeEach + void setUp() throws Exception { + this.root = certificateHelper.rootCertificate("CN=Root"); + this.intermediate = certificateHelper.intermediateCertificate("CN=Intermediate", root); + this.server = certificateHelper.leafCertificate("CN=Server", root); + this.client = certificateHelper.leafCertificate("CN=Client", root); + this.clientWithIntermediate = certificateHelper.leafCertificate("CN=ClientWithIntermediate", intermediate); + } + + @Test + void readAuthentication() throws Exception { + var serverKeyManager = new AdvancedTlsX509KeyManager(); + serverKeyManager.updateIdentityCredentials(server.keyPair().getPrivate(), + new X509Certificate[] {server.certificate()}); + + var clientKeyManager = new AdvancedTlsX509KeyManager(); + clientKeyManager.updateIdentityCredentials(client.keyPair().getPrivate(), + new X509Certificate[] {client.certificate()}); + + var authentication = readAuthentication(serverKeyManager, clientKeyManager); + assertNotNull(authentication); + assertInstanceOf(X509Certificate.class, authentication.getCredentials()); + X509Certificate certificate = (X509Certificate) authentication.getCredentials(); + assertEquals("CN=Client", certificate.getSubjectX500Principal().toString()); + } + + @Test + void readAuthenticationWithIntermediateCertificate() throws Exception { + var serverKeyManager = new AdvancedTlsX509KeyManager(); + serverKeyManager.updateIdentityCredentials(server.keyPair().getPrivate(), + new X509Certificate[] {server.certificate()}); + + var clientKeyManager = new AdvancedTlsX509KeyManager(); + clientKeyManager.updateIdentityCredentials(clientWithIntermediate.keyPair().getPrivate(), + new X509Certificate[] {clientWithIntermediate.certificate(), intermediate.certificate()}); + + var authentication = readAuthentication(serverKeyManager, clientKeyManager); + assertNotNull(authentication); + assertInstanceOf(X509Certificate.class, authentication.getCredentials()); + X509Certificate certificate = (X509Certificate) authentication.getCredentials(); + assertEquals("CN=ClientWithIntermediate", certificate.getSubjectX500Principal().toString()); + } + + private Authentication readAuthentication(AdvancedTlsX509KeyManager serverKeyManager, + AdvancedTlsX509KeyManager clientKeyManager) throws Exception { + var trustManager = AdvancedTlsX509TrustManager.newBuilder().build(); + trustManager.updateTrustCredentials(new X509Certificate[] {root.certificate()}); + + var serverCredentials = TlsServerCredentials.newBuilder() + .trustManager(trustManager) + .keyManager(serverKeyManager) + .clientAuth(ClientAuth.REQUIRE) + .build(); + + var interceptor = new AuthenticationReaderServerInterceptor(); + var healthStatusManager = new HealthStatusManager(); + var server = Grpc.newServerBuilderForPort(0, serverCredentials) + .addService(healthStatusManager.getHealthService()) + .intercept(interceptor) + .build(); + server.start(); + ManagedChannel channel = null; + try { + var clientCredentials = TlsChannelCredentials.newBuilder() + .trustManager(trustManager) + .keyManager(clientKeyManager) + .build(); + + channel = Grpc.newChannelBuilderForAddress("localhost", server.getPort(), clientCredentials).build(); + var client = HealthGrpc.newStub(channel); + + var clientCallComplete = new CompletableFuture(); + client.check(HealthCheckRequest.getDefaultInstance(), new FutureStreamObserver(clientCallComplete)); + clientCallComplete.get(); + + return interceptor.authenticationFuture().get(); + } finally { + if (channel != null) { + channel.shutdownNow(); + } + server.shutdownNow(); + } + } + + record FutureStreamObserver(CompletableFuture clientCallComplete) implements StreamObserver { + + @Override + public void onNext(HealthCheckResponse healthCheckResponse) {} + + @Override + public void onError(Throwable throwable) { + clientCallComplete.completeExceptionally(throwable); + } + + @Override + public void onCompleted() { + clientCallComplete.complete(null); + } + + } + + record AuthenticationReaderServerInterceptor( + SSLContextGrpcAuthenticationReader reader, + CompletableFuture authenticationFuture) implements ServerInterceptor { + + public AuthenticationReaderServerInterceptor() { + this(new SSLContextGrpcAuthenticationReader(), new CompletableFuture<>()); + } + + @Override + public Listener interceptCall(ServerCall call, Metadata headers, + ServerCallHandler next) { + var authentication = reader.readAuthentication(call, headers); + authenticationFuture.complete(authentication); + return next.startCall(call, headers); + } +}}