WIP: test: qualify Redis mutual TLS client authentication #3

Draft
KofTwentyTwo wants to merge 2 commits from test/redis-mtls-1317 into feature/redis
pull from: test/redis-mtls-1317
2 changed files with 127 additions and 2 deletions

No files matched your search

+15 -2
View File
@@ -29,8 +29,21 @@ configure `.withTlsProvider(new QCodeReference(HostRedisTls.class))`; that provi
returns an SSLContext at runtime, and hostname verification stays enabled.
`.withTls(false)` is an explicit option for local test Redis. Real integration tests
verify authenticated TLS, untrusted-certificate rejection, wrong-host rejection,
and no plaintext fallback. Client-certificate authentication, certificate rotation
and failover acceptance remain open; only standalone endpoints are implemented.
and no plaintext fallback. Mutual-TLS tests require a separate trusted client
identity, reject missing or untrusted client certificates, and verify framework
health after a successful authenticated connection. Certificates and keys are
generated only for the disposable fixture.
The TLS provider is resolved when the Redis runtime starts. Replacing its context
alone does not reload existing pools or subscribers. For certificate replacement,
stop dependent services and restart the Redis runtime through the application
lifecycle, then restart its dependents. Qualification replaces both server and
client certificates on the same Redis endpoint: stale material fails a fresh
handshake, health becomes DOWN, and runtime restart with updated material restores
PONG and health UP while closing the old pool. This is a restart procedure, not
zero-downtime certificate rotation. Live reload, trust-chain/revocation policies,
subscriber recovery across certificate changes and failover acceptance remain open;
only standalone endpoints are implemented.
QQQ's application launcher starts the registered runtime service. Startup validates
configuration and pings every connection before exposing any of the named pools.
@@ -32,6 +32,7 @@ import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManagerFactory;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import redis.clients.jedis.Protocol;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.testcontainers.containers.GenericContainer;
@@ -104,6 +105,117 @@ class RedisTlsTest
}
}
/***************************************************************************
** Redis must require a trusted client identity as well as server verification.
***************************************************************************/
@Test
void mutualTlsRequiresTrustedClientCertificate() throws Exception
{
try(GenericContainer<?> redis = new GenericContainer<>(DockerImageName.parse("redis:7.4-alpine")).withExposedPorts(6379))
{
Path server = Files.createDirectory(temporary.resolve("server"));
Path client = Files.createDirectory(temporary.resolve("client"));
String host = redis.getHost();
String identity = (host.matches("[0-9.]+") || host.contains(":") ? "ip:" : "dns:") + host;
SSLContext trustOnly = certificate(server, identity);
certificate(client, "dns:client-fixture.invalid");
redis.withCopyFileToContainer(MountableFile.forHostPath(server.resolve("cert.pem"), 0444), "/server-cert.pem")
.withCopyFileToContainer(MountableFile.forHostPath(server.resolve("key.pem"), 0444), "/server-key.pem")
.withCopyFileToContainer(MountableFile.forHostPath(client.resolve("cert.pem"), 0444), "/client-trust.pem")
.withCommand("redis-server", "--port", "0", "--tls-port", "6379",
"--tls-cert-file", "/server-cert.pem", "--tls-key-file", "/server-key.pem",
"--tls-ca-cert-file", "/client-trust.pem", "--tls-auth-clients", "yes",
"--requirepass", RedisAuthenticationTest.TEST_PASSWORD);
redis.start();
var settings = new RedisConnectionConfig().withHost(host).withPort(redis.getMappedPort(6379))
.withConnectionTimeoutMillis(1000).withSocketTimeoutMillis(1000)
.withTlsProvider(new QCodeReference(TrustedContext.class))
.withCredentialsProvider(new QCodeReference(RedisAuthenticationTest.ValidCredentials.class));
var instance = RedisRuntimeTest.hostInstance();
new RedisQBitProducer().withQBitConfig(new RedisQBitConfig().withConnection("presence", settings)).produce(instance);
var runtime = new RedisRuntimeService();
try
{
TrustedContext.context = trustOnly;
assertThrows(QException.class, () -> runtime.start(instance), "Server trust alone must not satisfy mutual TLS");
assertThrows(IllegalStateException.class, () -> RedisRuntimeService.connection(instance, "presence"));
TrustedContext.context = clientContext(server, server);
assertThrows(QException.class, () -> runtime.start(instance), "An unrelated client identity must not authenticate");
assertThrows(IllegalStateException.class, () -> RedisRuntimeService.connection(instance, "presence"));
TrustedContext.context = clientContext(server, client);
runtime.start(instance);
assertEquals("PONG", RedisRuntimeService.connection(instance, "presence").client().ping());
assertEquals(com.kingsrook.qqq.backend.core.health.HealthStatus.UP, new RedisHealthCheck().run(
new com.kingsrook.qqq.backend.core.health.HealthCheckInput().withQInstance(instance)).getStatus());
// Rotate both identities on the same endpoint; existing TLS sessions do not re-handshake.
Path rotatedServer = Files.createDirectory(temporary.resolve("rotated-server"));
Path rotatedClient = Files.createDirectory(temporary.resolve("rotated-client"));
certificate(rotatedServer, identity);
certificate(rotatedClient, "dns:rotated-client-fixture.invalid");
redis.copyFileToContainer(MountableFile.forHostPath(rotatedServer.resolve("cert.pem"), 0444), "/rotated-server-cert.pem");
redis.copyFileToContainer(MountableFile.forHostPath(rotatedServer.resolve("key.pem"), 0444), "/rotated-server-key.pem");
redis.copyFileToContainer(MountableFile.forHostPath(rotatedClient.resolve("cert.pem"), 0444), "/rotated-client-trust.pem");
var oldClient = RedisRuntimeService.connection(instance, "presence").client();
var configured = oldClient.sendCommand(Protocol.Command.CONFIG, "SET",
"tls-cert-file", "/rotated-server-cert.pem", "tls-key-file", "/rotated-server-key.pem",
"tls-ca-cert-file", "/rotated-client-trust.pem");
assertEquals("OK", new String((byte[]) configured, java.nio.charset.StandardCharsets.UTF_8));
oldClient.getPool().clear();
assertThrows(redis.clients.jedis.exceptions.JedisException.class, oldClient::ping,
"A fresh connection with stale trust and client keys must fail");
assertEquals(0, oldClient.getPool().getNumActive());
var healthInput = new com.kingsrook.qqq.backend.core.health.HealthCheckInput().withQInstance(instance);
assertEquals(com.kingsrook.qqq.backend.core.health.HealthStatus.DOWN, new RedisHealthCheck().run(healthInput).getStatus());
TrustedContext.context = clientContext(rotatedServer, rotatedClient);
assertThrows(redis.clients.jedis.exceptions.JedisException.class, oldClient::ping,
"Changing the provider alone must not be mistaken for live TLS reload");
runtime.stop();
runtime.afterApplicationStop();
assertTrue(oldClient.getPool().isClosed());
assertThrows(IllegalStateException.class, () -> RedisRuntimeService.connection(instance, "presence"));
runtime.start(instance);
var newClient = RedisRuntimeService.connection(instance, "presence").client();
assertNotSame(oldClient, newClient);
assertEquals("PONG", newClient.ping());
assertEquals(com.kingsrook.qqq.backend.core.health.HealthStatus.UP, new RedisHealthCheck().run(healthInput).getStatus());
assertEquals(0, newClient.getPool().getNumActive());
}
finally
{
runtime.stop();
runtime.afterApplicationStop();
TrustedContext.context = null;
}
}
}
/***************************************************************************
** Keep server trust independent of the client key presented to Redis.
***************************************************************************/
private static SSLContext clientContext(Path server, Path client) throws Exception
{
KeyStore keys = KeyStore.getInstance("PKCS12");
try(var input = Files.newInputStream(client.resolve("test.p12")))
{
keys.load(input, "test-only-password".toCharArray());
}
var keyManagers = javax.net.ssl.KeyManagerFactory.getInstance(javax.net.ssl.KeyManagerFactory.getDefaultAlgorithm());
keyManagers.init(keys, "test-only-password".toCharArray());
KeyStore trust = KeyStore.getInstance(KeyStore.getDefaultType());
trust.load(null, null);
try(var input = Files.newInputStream(server.resolve("cert.pem")))
{
trust.setCertificateEntry("server", java.security.cert.CertificateFactory.getInstance("X.509").generateCertificate(input));
}
var trustManagers = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
trustManagers.init(trust);
SSLContext context = SSLContext.getInstance("TLS");
context.init(keyManagers.getKeyManagers(), trustManagers.getTrustManagers(), null);
return (context);
}
/***************************************************************************
** JDK keytool avoids internal JDK APIs and external certificate dependencies.
***************************************************************************/