From 2e7ad97f3fd1ffc0abcb5fb899c40491a06fe633 Mon Sep 17 00:00:00 2001 From: James Maes Date: Thu, 22 Oct 2026 16:48:37 -0500 Subject: [PATCH 1/2] test: qualify Redis mutual TLS client authentication Require separate trusted client keys with real Redis TLS, reject missing and unrelated identities, and verify framework health after authenticated startup. Full verify passes24tests and coverage gates. No production runtime changes. Co-Authored-By: Codex --- README.md | 7 +- .../kingsrook/qbits/redis/RedisTlsTest.java | 77 +++++++++++++++++++ 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 11c9876..c5c5d2c 100644 --- a/README.md +++ b/README.md @@ -29,8 +29,11 @@ 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. Certificate rotation 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. diff --git a/src/test/java/com/kingsrook/qbits/redis/RedisTlsTest.java b/src/test/java/com/kingsrook/qbits/redis/RedisTlsTest.java index 6b0a403..0e97067 100644 --- a/src/test/java/com/kingsrook/qbits/redis/RedisTlsTest.java +++ b/src/test/java/com/kingsrook/qbits/redis/RedisTlsTest.java @@ -104,6 +104,83 @@ 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()); + } + 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. ***************************************************************************/ -- 2.54.0 From 54adcfed62b0c6b3313cffaeb2b70b4aa7fca0f0 Mon Sep 17 00:00:00 2001 From: James Maes Date: Thu, 22 Oct 2026 17:01:10 -0500 Subject: [PATCH 2/2] test: qualify Redis certificate replacement on restart Co-Authored-By: Codex --- README.md | 14 ++++++-- .../kingsrook/qbits/redis/RedisTlsTest.java | 35 +++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c5c5d2c..8321186 100644 --- a/README.md +++ b/README.md @@ -32,8 +32,18 @@ verify authenticated TLS, untrusted-certificate rejection, wrong-host rejection, 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. Certificate rotation and failover -acceptance remain open; only standalone endpoints are implemented. +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. diff --git a/src/test/java/com/kingsrook/qbits/redis/RedisTlsTest.java b/src/test/java/com/kingsrook/qbits/redis/RedisTlsTest.java index 0e97067..3146574 100644 --- a/src/test/java/com/kingsrook/qbits/redis/RedisTlsTest.java +++ b/src/test/java/com/kingsrook/qbits/redis/RedisTlsTest.java @@ -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; @@ -146,6 +147,40 @@ class RedisTlsTest 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 { -- 2.54.0