diff --git a/README.md b/README.md index 11c9876..8321186 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/test/java/com/kingsrook/qbits/redis/RedisTlsTest.java b/src/test/java/com/kingsrook/qbits/redis/RedisTlsTest.java index 6b0a403..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; @@ -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. ***************************************************************************/