Skip to content

Commit

Permalink
More mTLS test cases
Browse files Browse the repository at this point in the history
This PR adds more test cases for the server side of mTLS. These came from an internal user that reported expired certs being accepted.

The test cases check a normal cert, an expired cert, and an untrusted cert. The previous RequestCertificateSpec only tests the "happy path" with the valid cert. These tests will prevent issues similar to #4116.

It turns out that the behavior for expired certs is correct. When a cert is directly added to the trust store (not just its CA), the JDK does not check expiry. I think we should match that behavior.

Also contains a small change to SelfSignedSslBuilder to make it actually use the configured trust store. This has no security implications, it just makes the tests work.
  • Loading branch information
yawkat committed Mar 15, 2023
1 parent e9589a8 commit 4c02eb0
Show file tree
Hide file tree
Showing 2 changed files with 208 additions and 1 deletion.
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,8 @@ public Optional<SslContext> build(SslConfiguration ssl, HttpVersion httpVersion)
LOG.warn("HTTP Server is configured to use a self-signed certificate ('build-self-signed' is set to true). This configuration should not be used in a production environment as self-signed certificates are inherently insecure.");
}
SelfSignedCertificate ssc = new SelfSignedCertificate();
final SslContextBuilder sslBuilder = SslContextBuilder.forServer(ssc.certificate(), ssc.privateKey());
final SslContextBuilder sslBuilder = SslContextBuilder.forServer(ssc.certificate(), ssc.privateKey())
.trustManager(getTrustManagerFactory(ssl));
CertificateProvidedSslBuilder.setupSslBuilder(sslBuilder, ssl, httpVersion);
return Optional.of(sslBuilder.build());
} catch (CertificateException | SSLException e) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
package io.micronaut.http.server.netty.ssl

import io.micronaut.context.ApplicationContext
import io.micronaut.context.annotation.Requires
import io.micronaut.http.HttpRequest
import io.micronaut.http.annotation.Controller
import io.micronaut.http.annotation.Get
import io.micronaut.runtime.server.EmbeddedServer
import io.netty.handler.ssl.util.SelfSignedCertificate
import io.vertx.core.Vertx
import io.vertx.core.net.JksOptions
import io.vertx.ext.web.client.HttpResponse
import io.vertx.ext.web.client.WebClient
import io.vertx.ext.web.client.WebClientOptions
import spock.lang.Specification

import javax.net.ssl.SSLHandshakeException
import java.nio.file.Files
import java.nio.file.Path
import java.security.KeyStore
import java.security.cert.Certificate
import java.security.cert.X509Certificate
import java.time.Instant
import java.time.temporal.ChronoUnit
import java.util.concurrent.CompletableFuture
import java.util.concurrent.ExecutionException

class RequestCertificateSpec2 extends Specification {
def normal() {
given:
def certificate = new SelfSignedCertificate()

def keyStorePath = Files.createTempFile("micronaut-test-key-store", "pkcs12")
def trustStorePath = Files.createTempFile("micronaut-test-trust-store", "pkcs12")

writeStores(certificate, keyStorePath, trustStorePath)

def ctx = ApplicationContext.run([
"spec.name" : "RequestCertificateSpec2",
"micronaut.http.client.read-timeout" : "15s",
'micronaut.server.ssl.enabled' : true,
'micronaut.server.ssl.port' : -1,
'micronaut.server.ssl.buildSelfSigned': true,
'micronaut.ssl.clientAuthentication' : "need",
'micronaut.ssl.trust-store.path' : 'file://' + trustStorePath.toString(),
'micronaut.ssl.trust-store.type' : 'JKS',
'micronaut.ssl.trust-store.password' : '123456',
])

def server = ctx.getBean(EmbeddedServer)
server.start()

def vertx = Vertx.vertx()
def client = WebClient.create(vertx, new WebClientOptions()
.setTrustAll(true)
.setSsl(true)
.setKeyCertOptions(new JksOptions().setPath(keyStorePath.toString()).setPassword(""))
.setTrustStoreOptions(new JksOptions().setPath(trustStorePath.toString()).setPassword("123456"))
)

when:
def future = new CompletableFuture<HttpResponse<?>>()
client.get(server.port, "localhost", "/mtls").send { if (it.failed()) future.completeExceptionally(it.cause()) else future.complete(it.result()) }
def response = future.get()
then:
response.bodyAsString() == 'CN=localhost'
response.statusCode() == 200

cleanup:
vertx.close()
ctx.close()
Files.deleteIfExists(keyStorePath)
Files.deleteIfExists(trustStorePath)
}

def expired() {
// this is intended behavior: an expired client cert DOES NOT lead to a handshake failure. This is JDK behavior:
// when a cert is directly in the trust store, expiry is not checked. expiry is only checked if the cert is
// signed by a CA that is in the trust store.

given:
def certificate = new SelfSignedCertificate(Date.from(Instant.now().minus(5, ChronoUnit.HOURS)), Date.from(Instant.now().minus(1, ChronoUnit.HOURS)))

def keyStorePath = Files.createTempFile("micronaut-test-key-store", "pkcs12")
def trustStorePath = Files.createTempFile("micronaut-test-trust-store", "pkcs12")

writeStores(certificate, keyStorePath, trustStorePath)

def ctx = ApplicationContext.run([
"spec.name" : "RequestCertificateSpec2",
"micronaut.http.client.read-timeout" : "15s",
'micronaut.server.ssl.enabled' : true,
'micronaut.server.ssl.port' : -1,
'micronaut.server.ssl.buildSelfSigned': true,
'micronaut.ssl.clientAuthentication' : "need",
'micronaut.ssl.trust-store.path' : 'file://' + trustStorePath.toString(),
'micronaut.ssl.trust-store.type' : 'JKS',
'micronaut.ssl.trust-store.password' : '123456',
])

def server = ctx.getBean(EmbeddedServer)
server.start()

def vertx = Vertx.vertx()
def client = WebClient.create(vertx, new WebClientOptions()
.setTrustAll(true)
.setSsl(true)
.setKeyCertOptions(new JksOptions().setPath(keyStorePath.toString()).setPassword(""))
.setTrustStoreOptions(new JksOptions().setPath(trustStorePath.toString()).setPassword("123456"))
)

when:
def future = new CompletableFuture<HttpResponse<?>>()
client.get(server.port, "localhost", "/mtls").send { if (it.failed()) future.completeExceptionally(it.cause()) else future.complete(it.result()) }
def response = future.get()
then:
response.bodyAsString() == 'CN=localhost'
response.statusCode() == 200

cleanup:
vertx.close()
ctx.close()
Files.deleteIfExists(keyStorePath)
Files.deleteIfExists(trustStorePath)
}

def untrusted() {
given:
def clientCert = new SelfSignedCertificate()
// for the client to send the cert, we still need the same CN in the trust store
def serverExpectsCert = new SelfSignedCertificate()

def keyStorePath = Files.createTempFile("micronaut-test-key-store", "pkcs12")
def trustStorePath = Files.createTempFile("micronaut-test-trust-store", "pkcs12")

writeStores(clientCert, keyStorePath, null)
writeStores(serverExpectsCert, null, trustStorePath)

def ctx = ApplicationContext.run([
"spec.name" : "RequestCertificateSpec2",
"micronaut.http.client.read-timeout" : "15s",
'micronaut.server.ssl.enabled' : true,
'micronaut.server.ssl.port' : -1,
'micronaut.server.ssl.buildSelfSigned': true,
'micronaut.ssl.clientAuthentication' : "need",
'micronaut.ssl.trust-store.path' : 'file://' + trustStorePath.toString(),
'micronaut.ssl.trust-store.type' : 'JKS',
'micronaut.ssl.trust-store.password' : '123456',
])

def server = ctx.getBean(EmbeddedServer)
server.start()

def vertx = Vertx.vertx()
def client = WebClient.create(vertx, new WebClientOptions()
.setTrustAll(true)
.setSsl(true)
.setKeyCertOptions(new JksOptions().setPath(keyStorePath.toString()).setPassword(""))
.setTrustStoreOptions(new JksOptions().setPath(trustStorePath.toString()).setPassword("123456"))
)

when:
def future = new CompletableFuture<HttpResponse<?>>()
client.get(server.port, "localhost", "/mtls").send { if (it.failed()) future.completeExceptionally(it.cause()) else future.complete(it.result()) }
def response = future.get()
then:
def e = thrown ExecutionException
e.cause instanceof SSLHandshakeException

cleanup:
vertx.close()
ctx.close()
Files.deleteIfExists(keyStorePath)
Files.deleteIfExists(trustStorePath)
}

private void writeStores(SelfSignedCertificate certificate, Path keyStorePath, Path trustStorePath) {
if (keyStorePath != null) {
KeyStore ks = KeyStore.getInstance("PKCS12")
ks.load(null, null)
ks.setKeyEntry("key", certificate.key(), "".toCharArray(), new Certificate[]{certificate.cert()})
try (OutputStream os = Files.newOutputStream(keyStorePath)) {
ks.store(os, "".toCharArray())
}
}

if (trustStorePath != null) {
KeyStore ts = KeyStore.getInstance("JKS")
ts.load(null, null)
ts.setCertificateEntry("cert", certificate.cert())
try (OutputStream os = Files.newOutputStream(trustStorePath)) {
ts.store(os, "123456".toCharArray())
}
}
}

@Controller
@Requires(property = "spec.name", value = "RequestCertificateSpec2")
static class TestController {
@Get('/mtls')
String name(HttpRequest<?> request) {
def cert = request.getCertificate().get() as X509Certificate
cert.issuerX500Principal.name
}
}
}

0 comments on commit 4c02eb0

Please sign in to comment.