diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java index 9d9c78d31..d5725a8e9 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java @@ -680,6 +680,7 @@ private void metrics() { @Override public void onPostLoad() { + ensureCommunicationSecret(); // auto conversion for Shop.yml if (plugin.getShopFile().isJustCreated()) { if (!plugin.getGui().isJustCreated() && !getServerData().isVoteShopConverted()) { @@ -881,6 +882,24 @@ public void run() { } + private void ensureCommunicationSecret() { + try { + boolean created = com.bencodez.votingplugin.proxy.security.SharedSecretKeyFile + .ensure(getDataFolder().toPath().resolve("secretkey.key")); + if (created) getLogger().info("Created secretkey.key for VotingPlugin communication security"); + if (!bungeeSettings.isCommunicationEncryption()) getLogger().warning( + "CommunicationEncryption is disabled. Copy the proxy secretkey.key to every VotingPlugin node, enable CommunicationEncryption everywhere, and restart (recommended)."); + } catch (java.io.IOException failure) { + boolean required = bungeeSettings.isCommunicationEncryption() + || com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator.Mode + .parse(bungeeSettings.getSharedTransportAuthentication()) + == com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator.Mode.REQUIRED; + if (required) throw new IllegalStateException( + "Unable to prepare required VotingPlugin communication secretkey.key", failure); + getLogger().warning("Unable to create optional secretkey.key; continuing with legacy plaintext/unsigned communication. Fix data-folder permissions before enabling communication security."); + } + } + private void startBackendHostedControl() { HostedControlManager.HostConfiguration configuration = readBackendHostedControlConfiguration(); try { @@ -2056,11 +2075,11 @@ private void registerEvents() { */ @Override public void reload() { - reloadPlugin(false, true); + reloadPlugin(false, true, true); } public void reloadAll() { - reloadPlugin(true, true); + reloadPlugin(true, true, true); } /** Captures Bukkit presence while the lifecycle caller owns platform access. */ @@ -2082,10 +2101,13 @@ static UUID placeholderStorageUuid(Player player, boolean onlineMode) { /** Reloads configuration applied by Control before its result is acknowledged. */ public void reloadFromControl() { - reloadPlugin(false, false); + // Control publishes a separately prepared handler only after validation and + // handoff. Keep the predecessor and its transport policy stable until then. + reloadPlugin(false, false, false); } - private void reloadPlugin(boolean userStorage, boolean reconcileHostedControl) { + private void reloadPlugin(boolean userStorage, boolean reconcileHostedControl, + boolean updateActiveBackendRuntime) { configFile.reloadData(); configFile.loadValues(); @@ -2106,20 +2128,7 @@ private void reloadPlugin(boolean userStorage, boolean reconcileHostedControl) { // Re-evaluate after storage has reloaded; UserManager keeps this lifecycle task unique. getVotingPluginUserManager().startSharedPointTransferRecovery(); - if (bungeeSettings.isUseBungeecoord()) { - BackendProxyHandler handler = getBackendProxyHandler(); - if (handler == null) { - loadBungeeHandler(); - handler = getBackendProxyHandler(); - } else { - handler.reloadPresenceReporting(); - } - if (userStorage && handler != null) { - handler.loadGlobalMysql(); - } - } else if (getBackendProxyHandler() != null) { - getBackendProxyHandler().disablePresenceReporting(); - } + reloadBackendProxyRuntime(updateActiveBackendRuntime, userStorage); checkYMLError(); plugin.loadVoteSites(); @@ -2151,6 +2160,34 @@ private void reloadPlugin(boolean userStorage, boolean reconcileHostedControl) { setUpdate(true); } + void reloadBackendProxyRuntime(boolean updateActiveRuntime, boolean userStorage) { + if (!updateActiveRuntime) return; + if (bungeeSettings.isUseBungeecoord()) { + BackendProxyHandler handler = getBackendProxyHandler(); + if (handler == null) { + loadBungeeHandler(); + handler = getBackendProxyHandler(); + } else { + reloadActiveBackendTransportSecurity(handler); + handler.reloadPresenceReporting(); + } + if (userStorage && handler != null) { + handler.loadGlobalMysql(); + } + } else if (getBackendProxyHandler() != null) { + getBackendProxyHandler().disablePresenceReporting(); + } + } + + void reloadActiveBackendTransportSecurity(BackendProxyHandler handler) { + try { + handler.reloadSharedTransportSecurity(); + } catch (RuntimeException failure) { + getLogger().warning("Backend transport security settings were not applied; the previous policy remains active"); + debug(failure); + } + } + private void loadVoteBroadcast() { ConfigurationSection sec = getConfigFile().getData().getConfigurationSection("VoteBroadcast"); BroadcastSettings settings = BroadcastSettings.load(sec); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandler.java index c1e6b9726..86f384a40 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandler.java @@ -31,6 +31,11 @@ import com.bencodez.votingplugin.backendproxy.voteparty.BackendVotePartySync; import com.bencodez.votingplugin.proxy.BungeeMethod; import com.bencodez.votingplugin.proxy.VotingPluginWire; +import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator; +import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator.Mode; +import com.bencodez.votingplugin.proxy.security.TransportEnvelopeEncryption; +import com.bencodez.votingplugin.proxy.security.TransportEnvelopeEncryption.Decryption; +import com.bencodez.votingplugin.proxy.security.TransportEnvelopeEncryption.Domain; import lombok.Getter; @@ -68,6 +73,10 @@ public class BackendProxyHandler implements Listener { private BackendVotePartySync votePartySync; private boolean persistVotePartyOnClose = true; private BackendProxyMessageRouter messageRouter; + private volatile TransportEnvelopeEncryption communicationEncryption; + private Mode sharedTransportMode; + private boolean communicationEncryptionEnabled; + private final AtomicBoolean encryptionFailureLogged = new AtomicBoolean(); @Getter private BungeeMethod method; @@ -107,19 +116,17 @@ private void load(boolean activatePresenceReporting) { plugin.debug("Loading backend proxy handler"); method = BungeeMethod.getByName(plugin.getBungeeSettings().getBungeeMethod()); plugin.getLogger().info("Using BungeeMethod: " + method.toString()); + try { + communicationEncryption = TransportEnvelopeEncryption.load( + plugin.getDataFolder().toPath().resolve("secretkey.key"), Domain.PROXY_BACKEND, + plugin.getBungeeSettings().isCommunicationEncryption()); + } catch (java.io.IOException failure) { + throw new IllegalStateException("Proxy communication encryption initialization failed", failure); + } + transportManager.setHttpEncryption(communicationEncryption); globalDataSync.load(); - globalMessageHandler = new GlobalMessageHandler() { - @Override - public void onMessage(JsonEnvelope envelope) { - BackendProxyHandler.this.dispatchIncomingAfterPublication(envelope, () -> super.onMessage(envelope)); - } - - @Override - public void sendMessage(JsonEnvelope envelope) { - transportManager.send(envelope); - } - }; + globalMessageHandler = new EncryptedGlobalMessageHandler(); presenceManager = new BackendPresenceManager(plugin, method, globalMessageHandler); votePartySync = new BackendVotePartySync(plugin); @@ -127,6 +134,10 @@ public void sendMessage(JsonEnvelope envelope) { processedVoteCache); messageRouter.register(globalMessageHandler, method); transportManager.start(method, globalMessageHandler, activatePresenceReporting); + if (method == BungeeMethod.REDIS || method == BungeeMethod.MQTT) { + sharedTransportMode = Mode.parse(plugin.getBungeeSettings().getSharedTransportAuthentication()); + communicationEncryptionEnabled = plugin.getBungeeSettings().isCommunicationEncryption(); + } if (plugin.getOptions().getServer().equalsIgnoreCase("pleaseset")) { plugin.getLogger().warning("Server name for bungee voting is not set, please set it"); @@ -137,6 +148,45 @@ public void sendMessage(JsonEnvelope envelope) { } } + private final class EncryptedGlobalMessageHandler extends GlobalMessageHandler { + @Override + public void onMessage(JsonEnvelope envelope) { + if (transportHandlesEncryption()) { + acceptDecrypted(envelope); + return; + } + Decryption decrypted = communicationEncryption.decrypt(envelope); + if (!decrypted.accepted()) { + if (encryptionFailureLogged.compareAndSet(false, true)) plugin.getLogger().warning( + "Proxy communication message rejected by encryption policy (" + decrypted.reason() + ")"); + return; + } + acceptDecrypted(decrypted.envelope()); + } + + private void acceptDecrypted(JsonEnvelope accepted) { + BackendProxyHandler.this.dispatchIncomingAfterPublication(accepted, () -> super.onMessage(accepted)); + } + + @Override + public void sendMessage(JsonEnvelope envelope) { + transportManager.send(transportHandlesEncryption() ? envelope : communicationEncryption.encrypt(envelope)); + } + } + + private boolean transportHandlesEncryption() { + return usesSharedBrokerSecurity() || method == BungeeMethod.HTTP; + } + + private boolean usesSharedBrokerSecurity() { + return method == BungeeMethod.REDIS || method == BungeeMethod.MQTT; + } + + private void acceptAlreadyDecrypted(JsonEnvelope envelope) { + if (globalMessageHandler instanceof EncryptedGlobalMessageHandler handler) handler.acceptDecrypted(envelope); + else if (globalMessageHandler != null) globalMessageHandler.onMessage(envelope); + } + /** Starts presence only after a staged handler reaches the atomic publication boundary. */ public void activatePresenceReporting() { if (presenceManager != null && !presenceReportingActivated) { @@ -182,16 +232,31 @@ public void activateInboundMessages() { activateOrderedVoteDispatch(); } - /** Routes an already accepted staged callback through the restored predecessor on rollback. */ + /** + * Routes a staged callback through the restored predecessor only when both + * runtimes enforced the exact same inbound cryptographic policy. A callback + * accepted under a different replacement policy must not bypass the restored + * runtime's authentication or encryption boundary as plaintext. + */ public void abortStagedInboundTo(BackendProxyHandler previous) { + BackendProxyHandler safeRollbackTarget = hasEquivalentInboundSecurity(previous) ? previous : null; synchronized (inboundPublication) { if (inboundPublished || inboundAborted) return; - inboundRollbackTarget = previous; + inboundRollbackTarget = safeRollbackTarget; inboundAborted = true; inboundPublication.notifyAll(); } } + private boolean hasEquivalentInboundSecurity(BackendProxyHandler previous) { + if (previous == null || method != previous.method) return false; + if (communicationEncryption == null || previous.communicationEncryption == null) { + if (communicationEncryption != previous.communicationEncryption) return false; + } else if (!communicationEncryption.hasEquivalentInboundPolicy(previous.communicationEncryption)) return false; + if (method != BungeeMethod.REDIS && method != BungeeMethod.MQTT) return true; + return transportManager.hasEquivalentSharedInboundPolicy(previous.transportManager); + } + void dispatchIncomingAfterPublication(JsonEnvelope envelope, Runnable localDispatch) { BackendProxyHandler rollbackTarget; synchronized (inboundPublication) { @@ -207,8 +272,7 @@ void dispatchIncomingAfterPublication(JsonEnvelope envelope, Runnable localDispa if (!inboundPublished && rollbackTarget == null) return; } if (rollbackTarget != null) { - GlobalMessageHandler rollbackHandler = rollbackTarget.globalMessageHandler; - if (rollbackHandler != null) rollbackHandler.onMessage(envelope); + rollbackTarget.acceptAlreadyDecrypted(envelope); return; } // Reward-bearing proxy votes construct an asynchronous PlayerVoteEvent. The @@ -861,6 +925,32 @@ public void reloadPresenceReporting() { } } + /** Refreshes policies captured when a Redis or MQTT transport first started. */ + public void reloadSharedTransportSecurity() { + if (method != BungeeMethod.REDIS && method != BungeeMethod.MQTT) return; + Mode requestedMode = Mode.parse(plugin.getBungeeSettings().getSharedTransportAuthentication()); + boolean requestedEncryption = plugin.getBungeeSettings().isCommunicationEncryption(); + try { + java.nio.file.Path keyFile = plugin.getDataFolder().toPath().resolve("secretkey.key"); + TransportEnvelopeEncryption candidateEncryption = TransportEnvelopeEncryption.load( + keyFile, Domain.PROXY_BACKEND, requestedEncryption); + SharedTransportEnvelopeAuthenticator candidateAuthenticator = + SharedTransportEnvelopeAuthenticator.load(keyFile, requestedMode); + boolean authenticatorChanged = !transportManager.hasEquivalentSharedTransportAuthenticator( + candidateAuthenticator); + boolean encryptionChanged = !transportManager.hasEquivalentSharedTransportEncryption(candidateEncryption); + if (!authenticatorChanged && !encryptionChanged) return; + transportManager.updateSharedTransportSecurity(authenticatorChanged ? candidateAuthenticator : null, + encryptionChanged ? candidateEncryption : null); + if (encryptionChanged) communicationEncryption = candidateEncryption; + encryptionFailureLogged.set(false); + sharedTransportMode = requestedMode; + communicationEncryptionEnabled = requestedEncryption; + } catch (java.io.IOException failure) { + throw new IllegalStateException("Shared backend transport security reload failed", failure); + } + } + public void disablePresenceReporting() { if (presenceManager != null && presenceReportingActivated) { presenceManager.stop(); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/BackendProxyTransportManager.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/BackendProxyTransportManager.java index 939ea2047..e9ae0279a 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/BackendProxyTransportManager.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/BackendProxyTransportManager.java @@ -11,6 +11,7 @@ import com.bencodez.votingplugin.backendproxy.cache.ProcessedVoteCache; import com.bencodez.votingplugin.proxy.BungeeMethod; import com.bencodez.votingplugin.proxy.VotingPluginWire; +import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator; /** * Selects and owns the active backend-to-proxy transport. @@ -22,6 +23,7 @@ public class BackendProxyTransportManager { private final VotingPluginMain plugin; private final ProcessedVoteCache processedVoteCache; + private com.bencodez.votingplugin.proxy.security.TransportEnvelopeEncryption httpEncryption; private BackendProxyTransport transport; private BackendProxyTransport preparedTransport; private BackendProxyTransport retiredTransport; @@ -51,6 +53,11 @@ public BackendProxyTransportManager(VotingPluginMain plugin, ProcessedVoteCache this.processedVoteCache = processedVoteCache; } + public void setHttpEncryption( + com.bencodez.votingplugin.proxy.security.TransportEnvelopeEncryption httpEncryption) { + this.httpEncryption = httpEncryption; + } + public void start(BungeeMethod method, GlobalMessageHandler messageHandler) { start(method, messageHandler, true); } @@ -68,7 +75,7 @@ public void start(BungeeMethod method, GlobalMessageHandler messageHandler, bool transport = new SocketBackendProxyTransport(plugin); break; case HTTP: - transport = new HttpBackendProxyTransport(plugin); + transport = new HttpBackendProxyTransport(plugin, httpEncryption); break; case REDIS: transport = new RedisBackendProxyTransport(plugin, processedVoteCache); @@ -111,6 +118,41 @@ public synchronized void send(JsonEnvelope envelope) { } } + public synchronized void updateSharedTransportSecurity(SharedTransportEnvelopeAuthenticator authenticator, + com.bencodez.votingplugin.proxy.security.TransportEnvelopeEncryption encryption) { + if (transport instanceof RedisBackendProxyTransport redis) redis.updateSecurity(authenticator, encryption); + else if (transport instanceof MqttBackendProxyTransport mqtt) mqtt.updateSecurity(authenticator, encryption); + else throw new IllegalStateException("No active shared backend transport to update"); + } + + public synchronized boolean hasEquivalentSharedTransportAuthenticator( + SharedTransportEnvelopeAuthenticator authenticator) { + SharedInboundPolicy policy = sharedInboundPolicySnapshot(); + return policy != null && policy.authenticator() != null + && policy.authenticator().hasEquivalentInboundPolicy(authenticator); + } + + public synchronized boolean hasEquivalentSharedTransportEncryption( + com.bencodez.votingplugin.proxy.security.TransportEnvelopeEncryption encryption) { + SharedInboundPolicy policy = sharedInboundPolicySnapshot(); + return policy != null && policy.encryption() != null + && policy.encryption().hasEquivalentInboundPolicy(encryption); + } + + private synchronized SharedInboundPolicy sharedInboundPolicySnapshot() { + if (transport instanceof RedisBackendProxyTransport redis) return redis.sharedInboundPolicySnapshot(); + if (transport instanceof MqttBackendProxyTransport mqtt) return mqtt.sharedInboundPolicySnapshot(); + return null; + } + + /** Compares broker authentication and the destination bound into its MAC without nesting manager locks. */ + public boolean hasEquivalentSharedInboundPolicy(BackendProxyTransportManager other) { + if (other == null) return false; + SharedInboundPolicy current = sharedInboundPolicySnapshot(); + SharedInboundPolicy restored = other.sharedInboundPolicySnapshot(); + return current != null && current.hasEquivalentPolicy(restored); + } + private void acceptPreparedSend(JsonEnvelope envelope) { if (preparedSends.size() < MAX_PREPARED_SENDS) { preparedSends.addLast(envelope); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/HttpBackendProxyTransport.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/HttpBackendProxyTransport.java index 919c96f54..e0ee37c63 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/HttpBackendProxyTransport.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/HttpBackendProxyTransport.java @@ -17,8 +17,12 @@ import com.bencodez.simpleapi.servercomm.http.HttpBackendTransportConnector; import com.bencodez.simpleapi.servercomm.http.HttpClientCredentialStore; import com.bencodez.simpleapi.servercomm.http.HttpConnectionCode; +import com.bencodez.simpleapi.servercomm.http.HttpEnvelopeWireCodec; import com.bencodez.simpleapi.servercomm.http.HttpTlsIdentity; import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.proxy.security.TransportEnvelopeEncryption; +import com.bencodez.votingplugin.proxy.security.TransportEnvelopeEncryption.Domain; +import com.bencodez.votingplugin.proxy.security.TransportEnvelopeHttpCodec; import com.bencodez.votingplugin.util.DurableFiles; /** Backend adapter for the secure outbound-only HTTP proxy transport. */ @@ -33,6 +37,8 @@ public final class HttpBackendProxyTransport implements BackendProxyTransport { private static final long SHUTDOWN_FLUSH_SECONDS = 5L; private static final ConcurrentHashMap DIRECTORY_OWNERS = new ConcurrentHashMap<>(); private final VotingPluginMain plugin; + private HttpEnvelopeWireCodec wireCodec; + private final java.util.concurrent.atomic.AtomicBoolean encryptionFailureLogged = new java.util.concurrent.atomic.AtomicBoolean(); private final Object lifecycle = new Object(); private final CountDownLatch startupComplete = new CountDownLatch(1); private final CountDownLatch credentialRestoreComplete = new CountDownLatch(1); @@ -68,6 +74,29 @@ public HttpBackendProxyTransport(VotingPluginMain plugin) { this.plugin = plugin; } + HttpBackendProxyTransport(VotingPluginMain plugin, TransportEnvelopeEncryption encryption) { + this.plugin = plugin; + if (encryption != null) wireCodec = createWireCodec(encryption); + } + + private HttpEnvelopeWireCodec loadWireCodec() { + try { + TransportEnvelopeEncryption encryption = TransportEnvelopeEncryption.load( + plugin.getDataFolder().toPath().resolve("secretkey.key"), Domain.PROXY_BACKEND, + plugin.getBungeeSettings().isCommunicationEncryption()); + return createWireCodec(encryption); + } catch (java.io.IOException failure) { + throw new IllegalStateException("HTTP communication encryption initialization failed", failure); + } + } + + private HttpEnvelopeWireCodec createWireCodec(TransportEnvelopeEncryption encryption) { + return new TransportEnvelopeHttpCodec(encryption, reason -> { + if (encryptionFailureLogged.compareAndSet(false, true)) plugin.getLogger().warning( + "HTTP proxy message rejected by communication encryption policy (" + reason + ")"); + }); + } + @Override public void start(GlobalMessageHandler messageHandler) { start(messageHandler, true); @@ -109,6 +138,7 @@ private void start(Path directory, String serverId, String connectionCode, this.restoreUnenrolledState = restoreUnenrolledState; this.inboundActive = inboundActive; this.published = inboundActive; + if (wireCodec == null) wireCodec = loadWireCodec(); started = true; worker = new Thread(() -> initialize(directory, serverId, connectionCode, messageHandler, retryInitialization, restoreUnenrolledState), @@ -119,6 +149,7 @@ private void start(Path directory, String serverId, String connectionCode, HttpBackendProxyTransport recreatePrepared() { HttpBackendProxyTransport restored = new HttpBackendProxyTransport(plugin); + restored.wireCodec = wireCodec; synchronized (lifecycle) { // Startup and handoff queues are one FIFO from the caller's perspective. // The handoff queue can still contain messages accepted by the previous @@ -308,7 +339,7 @@ private void initialize(Path directory, String serverId, String configuredCode, throw new IllegalStateException("Persisted HTTP identity belongs to a different backend Server name"); replacement = new HttpBackendTransportConnector(directory, envelope -> { dispatchAfterPublication(messageHandler, envelope); - }); + }, wireCodec); invokeConnectorLifecycle(replacement, "startPaused"); boolean discard = false; synchronized (lifecycle) { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/MqttBackendProxyTransport.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/MqttBackendProxyTransport.java index 85e2d1253..303e9b229 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/MqttBackendProxyTransport.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/MqttBackendProxyTransport.java @@ -1,5 +1,7 @@ package com.bencodez.votingplugin.backendproxy.transport; +import java.util.concurrent.atomic.AtomicBoolean; + import org.eclipse.paho.client.mqttv3.MqttException; import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope; @@ -7,6 +9,11 @@ import com.bencodez.simpleapi.servercomm.mqtt.MqttHandler; import com.bencodez.simpleapi.servercomm.mqtt.MqttServerComm; import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator; +import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator.Domain; +import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator.Mode; +import com.bencodez.votingplugin.proxy.security.TransportEnvelopeEncryption; +import com.bencodez.votingplugin.proxy.security.TransportEnvelopeEncryption.Decryption; import lombok.Getter; @@ -22,15 +29,41 @@ public class MqttBackendProxyTransport implements BackendProxyTransport { private String brokerUrl; private String username; private String password; + private volatile SharedTransportEnvelopeAuthenticator authenticator; + private volatile SharedTransportSecurityPolicy securityPolicy; + + void updateSecurity(SharedTransportEnvelopeAuthenticator replacementAuthenticator, + TransportEnvelopeEncryption replacementEncryption) { + SharedTransportSecurityPolicy current = securityPolicy(); + SharedTransportSecurityPolicy replacement = current.replace(replacementAuthenticator, replacementEncryption); + authenticator = replacement.authenticator(); + securityPolicy = replacement; + } + + private final AtomicBoolean authenticationFailureLogged = new AtomicBoolean(); public MqttBackendProxyTransport(VotingPluginMain plugin) { this.plugin = plugin; } + SharedInboundPolicy sharedInboundPolicySnapshot() { + SharedTransportSecurityPolicy policy = securityPolicy(); + return new SharedInboundPolicy(getClass(), subscriptionTopic, policy.authenticator(), policy.encryption()); + } + @Override public void start(GlobalMessageHandler messageHandler) { try { this.messageHandler = messageHandler; + authenticator = SharedTransportEnvelopeAuthenticator.load( + plugin.getDataFolder().toPath().resolve("secretkey.key"), + Mode.parse(plugin.getBungeeSettings().getSharedTransportAuthentication())); + securityPolicy = new SharedTransportSecurityPolicy(authenticator, TransportEnvelopeEncryption.load( + plugin.getDataFolder().toPath().resolve("secretkey.key"), + TransportEnvelopeEncryption.Domain.PROXY_BACKEND, + plugin.getBungeeSettings().isCommunicationEncryption())); + if (authenticator.mode() == Mode.COMPATIBILITY) plugin.getLogger().warning( + "SharedTransportAuthentication is COMPATIBILITY; unsigned MQTT messages are accepted during this rolling upgrade"); publishTopic = plugin.getBungeeSettings().getMqttPrefix() + "votingplugin/servers/proxy"; subscriptionTopic = plugin.getBungeeSettings().getMqttPrefix() + "votingplugin/servers/" + plugin.getOptions().getServer(); @@ -60,7 +93,8 @@ protected MqttServerComm createMqttServerComm() throws MqttException { private void startCapturedConnection() throws Exception { MqttHandler candidate = createMqttHandler(createMqttServerComm()); try { - candidate.subscribeEnvelopes(subscriptionTopic, (topic, envelope) -> messageHandler.onMessage(envelope)); + candidate.subscribeEnvelopes(subscriptionTopic, + (topic, envelope) -> acceptAuthenticatedEnvelope(envelope, topic)); mqttHandler = candidate; } catch (Exception subscriptionFailure) { try { @@ -72,6 +106,32 @@ private void startCapturedConnection() throws Exception { } } + void acceptAuthenticatedEnvelope(JsonEnvelope envelope, String topic) { + SharedTransportSecurityPolicy policy = securityPolicy(); + SharedTransportEnvelopeAuthenticator.Verification verification = policy.authenticator().verify(envelope, + Domain.MQTT_PROXY_BACKEND, topic); + if (!verification.accepted()) { + if (plugin != null && authenticationFailureLogged.compareAndSet(false, true)) plugin.getLogger() + .warning("MQTT shared transport message rejected by envelope authentication (" + + verification.rejection() + ")"); + return; + } + Decryption decrypted = policy.encryption().decrypt(verification.envelope()); + if (!decrypted.accepted()) { + if (plugin != null && authenticationFailureLogged.compareAndSet(false, true)) plugin.getLogger() + .warning("MQTT shared transport message rejected by encryption policy"); + return; + } + messageHandler.onMessage(decrypted.envelope()); + } + + private SharedTransportSecurityPolicy securityPolicy() { + SharedTransportSecurityPolicy current = securityPolicy; + if (current != null) return current; + return new SharedTransportSecurityPolicy(java.util.Objects.requireNonNull(authenticator), + TransportEnvelopeEncryption.disabled(TransportEnvelopeEncryption.Domain.PROXY_BACKEND)); + } + @Override public void validate() { if (mqttHandler == null) throw new IllegalStateException("MQTT backend proxy transport initialization failed"); @@ -88,7 +148,10 @@ public boolean send(JsonEnvelope envelope) { return false; } try { - mqttHandler.publishEnvelope(publishTopic, envelope); + SharedTransportSecurityPolicy policy = securityPolicy(); + JsonEnvelope encrypted = policy.encryption().encrypt(envelope); + mqttHandler.publishEnvelope(publishTopic, policy.authenticator().sign(encrypted, Domain.MQTT_PROXY_BACKEND, + plugin.getBungeeSettings().getServer(), publishTopic)); return true; } catch (Exception e) { if (plugin != null && plugin.getLogger() != null) { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/RedisBackendProxyTransport.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/RedisBackendProxyTransport.java index 2e9de9272..b707362bf 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/RedisBackendProxyTransport.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/RedisBackendProxyTransport.java @@ -4,6 +4,7 @@ import java.util.List; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import javax.net.ssl.SSLParameters; @@ -14,6 +15,12 @@ import com.bencodez.votingplugin.VotingPluginMain; import com.bencodez.votingplugin.backendproxy.cache.ProcessedVoteCache; import com.bencodez.votingplugin.proxy.VotingPluginWire; +import com.bencodez.votingplugin.proxy.redis.VotingPluginRedisChannels; +import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator; +import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator.Domain; +import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator.Mode; +import com.bencodez.votingplugin.proxy.security.TransportEnvelopeEncryption; +import com.bencodez.votingplugin.proxy.security.TransportEnvelopeEncryption.Decryption; import redis.clients.jedis.DefaultJedisClientConfig; import redis.clients.jedis.HostAndPort; @@ -81,6 +88,19 @@ public HandoffReplayBackpressureException(String message) { private GlobalMessageHandler messageHandler; private GlobalMessageHandler handoffMessageHandler; private String publishChannel; + private String subscriptionChannel; + private volatile SharedTransportEnvelopeAuthenticator authenticator; + private volatile SharedTransportSecurityPolicy securityPolicy; + + void updateSecurity(SharedTransportEnvelopeAuthenticator replacementAuthenticator, + TransportEnvelopeEncryption replacementEncryption) { + SharedTransportSecurityPolicy current = securityPolicy(); + SharedTransportSecurityPolicy replacement = current.replace(replacementAuthenticator, replacementEncryption); + authenticator = replacement.authenticator(); + securityPolicy = replacement; + } + + private final AtomicBoolean authenticationFailureLogged = new AtomicBoolean(); public RedisBackendProxyTransport(VotingPluginMain plugin) { this(plugin, new ProcessedVoteCache()); @@ -94,7 +114,22 @@ public RedisBackendProxyTransport(VotingPluginMain plugin, ProcessedVoteCache pr @Override public void start(GlobalMessageHandler messageHandler) { this.messageHandler = messageHandler; - publishChannel = plugin.getBungeeSettings().getRedisPrefix() + "VotingPlugin"; + try { + authenticator = SharedTransportEnvelopeAuthenticator.load( + plugin.getDataFolder().toPath().resolve("secretkey.key"), + Mode.parse(plugin.getBungeeSettings().getSharedTransportAuthentication())); + securityPolicy = new SharedTransportSecurityPolicy(authenticator, TransportEnvelopeEncryption.load( + plugin.getDataFolder().toPath().resolve("secretkey.key"), + TransportEnvelopeEncryption.Domain.PROXY_BACKEND, + plugin.getBungeeSettings().isCommunicationEncryption())); + } catch (java.io.IOException authenticationFailure) { + throw new IllegalStateException("Redis backend transport authentication initialization failed", + authenticationFailure); + } + warnIfCompatibilityMode(); + publishChannel = VotingPluginRedisChannels.proxy(plugin.getBungeeSettings().getRedisPrefix()); + subscriptionChannel = VotingPluginRedisChannels.backend(plugin.getBungeeSettings().getRedisPrefix(), + plugin.getBungeeSettings().getServer()); retiredAfterHandoff = false; standbySubscriber = !processedVoteCache.registerRedisSubscriber(subscriberIdentity); redisHandler = new RedisHandler(plugin.getBungeeSettings().getRedisHost(), @@ -111,13 +146,11 @@ public void debug(String message) { RedisHandler handler = redisHandler; CountDownLatch ready = new CountDownLatch(1); subscriptionReady = ready; - RedisListener listener = new RedisListener(handler, - plugin.getBungeeSettings().getRedisPrefix() + "VotingPlugin_" + plugin.getBungeeSettings().getServer(), + RedisListener listener = new RedisListener(handler, subscriptionChannel, (ch, payload) -> { try { JsonEnvelope envelope = com.bencodez.simpleapi.servercomm.codec.JsonEnvelopeCodec.decode(payload); - String deliveryId = envelope.getFields().get(VotingPluginWire.K_REDIS_DELIVERY_ID); - dispatchReceivedSubscriberEnvelope(envelope, deliveryId); + acceptAuthenticatedEnvelope(envelope, ch); } catch (Exception e) { plugin.debug("Redis decode failed: " + e.getMessage()); } @@ -132,6 +165,47 @@ public void onSubscribe(String channel, int subscribedChannels) { listenerThread.start(); } + SharedInboundPolicy sharedInboundPolicySnapshot() { + SharedTransportSecurityPolicy policy = securityPolicy(); + return new SharedInboundPolicy(getClass(), subscriptionChannel, policy.authenticator(), policy.encryption()); + } + + void acceptAuthenticatedEnvelope(JsonEnvelope envelope, String channel) { + SharedTransportSecurityPolicy policy = securityPolicy(); + SharedTransportEnvelopeAuthenticator.Verification verification = policy.authenticator().verify(envelope, + Domain.REDIS_PROXY_BACKEND, channel); + if (!verification.accepted()) { + logAuthenticationFailure("Redis", verification.rejection()); + return; + } + JsonEnvelope accepted = verification.envelope(); + String deliveryId = accepted.getFields().get(VotingPluginWire.K_REDIS_DELIVERY_ID); + Decryption decrypted = policy.encryption().decrypt(accepted); + if (!decrypted.accepted()) { + logAuthenticationFailure("Redis", SharedTransportEnvelopeAuthenticator.Rejection.INVALID); + return; + } + dispatchReceivedSubscriberEnvelope(decrypted.envelope(), deliveryId); + } + + private SharedTransportSecurityPolicy securityPolicy() { + SharedTransportSecurityPolicy current = securityPolicy; + if (current != null) return current; + return new SharedTransportSecurityPolicy(java.util.Objects.requireNonNull(authenticator), + TransportEnvelopeEncryption.disabled(TransportEnvelopeEncryption.Domain.PROXY_BACKEND)); + } + + private void warnIfCompatibilityMode() { + if (authenticator.mode() == Mode.COMPATIBILITY) plugin.getLogger().warning( + "SharedTransportAuthentication is COMPATIBILITY; unsigned Redis messages are accepted during this rolling upgrade"); + } + + private void logAuthenticationFailure(String transport, + SharedTransportEnvelopeAuthenticator.Rejection rejection) { + if (plugin != null && authenticationFailureLogged.compareAndSet(false, true)) plugin.getLogger().warning( + transport + " shared transport message rejected by envelope authentication (" + rejection + ")"); + } + /** * Redis Pub/Sub has already consumed this payload when its callback is invoked. * A bounded queue wait therefore cannot discard it: retry the same callback in @@ -845,8 +919,10 @@ private record BufferedHandoffDelivery(long sequence, JsonEnvelope envelope, Str @Override public boolean send(JsonEnvelope envelope) { if (redisHandler != null) { - redisHandler.publishEnvelope(publishChannel, - VotingPluginWire.withRedisDeliveryId(envelope)); + SharedTransportSecurityPolicy policy = securityPolicy(); + JsonEnvelope identified = VotingPluginWire.withRedisDeliveryId(policy.encryption().encrypt(envelope)); + redisHandler.publishEnvelope(publishChannel, policy.authenticator().sign(identified, Domain.REDIS_PROXY_BACKEND, + plugin.getBungeeSettings().getServer(), publishChannel)); return true; } return false; diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/SharedInboundPolicy.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/SharedInboundPolicy.java new file mode 100644 index 000000000..3967e8d05 --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/SharedInboundPolicy.java @@ -0,0 +1,16 @@ +package com.bencodez.votingplugin.backendproxy.transport; + +import java.util.Objects; + +import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator; +import com.bencodez.votingplugin.proxy.security.TransportEnvelopeEncryption; + +/** Immutable comparison view of the broker policy applied before backend dispatch. */ +record SharedInboundPolicy(Class transportType, String destination, + SharedTransportEnvelopeAuthenticator authenticator, TransportEnvelopeEncryption encryption) { + boolean hasEquivalentPolicy(SharedInboundPolicy other) { + return other != null && transportType == other.transportType && Objects.equals(destination, other.destination) + && authenticator != null && authenticator.hasEquivalentInboundPolicy(other.authenticator) + && encryption != null && encryption.hasEquivalentInboundPolicy(other.encryption); + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/SharedTransportSecurityPolicy.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/SharedTransportSecurityPolicy.java new file mode 100644 index 000000000..2423df0c2 --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/SharedTransportSecurityPolicy.java @@ -0,0 +1,22 @@ +package com.bencodez.votingplugin.backendproxy.transport; + +import java.util.Objects; + +import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator; +import com.bencodez.votingplugin.proxy.security.TransportEnvelopeEncryption; + +/** Immutable authentication and encryption generation for one shared transport. */ +record SharedTransportSecurityPolicy(SharedTransportEnvelopeAuthenticator authenticator, + TransportEnvelopeEncryption encryption) { + SharedTransportSecurityPolicy { + Objects.requireNonNull(authenticator, "authenticator"); + Objects.requireNonNull(encryption, "encryption"); + } + + SharedTransportSecurityPolicy replace(SharedTransportEnvelopeAuthenticator replacementAuthenticator, + TransportEnvelopeEncryption replacementEncryption) { + return new SharedTransportSecurityPolicy( + replacementAuthenticator == null ? authenticator : replacementAuthenticator, + replacementEncryption == null ? encryption : replacementEncryption); + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/config/BungeeSettings.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/config/BungeeSettings.java index 5c8a05ab7..deca6bb36 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/config/BungeeSettings.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/config/BungeeSettings.java @@ -42,9 +42,9 @@ public class BungeeSettings extends YMLFile { @Getter private String pluginMessagingChannel = "vp:vp"; - @ConfigDataBoolean(path = "PluginMessageEncryption") - @Getter - private boolean pluginMessageEncryption = false; + @ConfigDataBoolean(path = "CommunicationEncryption") + @Getter + private boolean communicationEncryption = false; @ConfigDataString(path = "Redis.Prefix") @Getter @@ -81,6 +81,10 @@ public class BungeeSettings extends YMLFile { @ConfigDataString(path = "MQTT.Prefix") @Getter private String mqttPrefix = ""; + + @ConfigDataString(path = "SharedTransportAuthentication") + @Getter + private String sharedTransportAuthentication = "COMPATIBILITY"; @ConfigDataString(path = "BungeeServer.Host") @Getter @@ -161,9 +165,14 @@ public BungeeSettings(VotingPluginMain plugin) { * * @return the formatted server name */ - public String getServerNameStorage() { - return getServer().replace("-", "_"); - } + public String getServerNameStorage() { + return getServer().replace("-", "_"); + } + + /** Legacy plugin-message framing setting retained only for existing configuration files. */ + public boolean isPluginMessageEncryption() { + return getData().getBoolean("PluginMessageEncryption", false); + } @Override public void loadValues() { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/ProxyRuntimeReplacementLifecycle.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/ProxyRuntimeReplacementLifecycle.java index 87de4d45a..00a21aea9 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/ProxyRuntimeReplacementLifecycle.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/ProxyRuntimeReplacementLifecycle.java @@ -7,6 +7,7 @@ private ProxyRuntimeReplacementLifecycle() { } /** Returns false for first initialization, which has no old runtime to prepare. */ public static boolean prepare(VotingPluginProxy previous) { if (previous == null) return false; + previous.validateReplacementTransportSecurity(); previous.prepareForRuntimeReplacement(); return true; } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java index b263f32f6..82f3f942b 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java @@ -59,6 +59,7 @@ import com.bencodez.simpleapi.servercomm.global.GlobalMessageListener; import com.bencodez.simpleapi.servercomm.global.GlobalMessageProxyHandler; import com.bencodez.simpleapi.servercomm.http.HttpEnrollmentAuthority; +import com.bencodez.simpleapi.servercomm.http.HttpEnvelopeWireCodec; import com.bencodez.simpleapi.servercomm.http.HttpProxyTransportServer; import com.bencodez.simpleapi.servercomm.http.HttpTlsIdentity; import com.bencodez.simpleapi.servercomm.mqtt.MqttHandler; @@ -91,6 +92,12 @@ import com.bencodez.votingplugin.proxy.multiproxy.MultiProxyServerSocketConfigurationBungee; import com.bencodez.votingplugin.proxy.presence.BackendPlayerPresenceTracker; import com.bencodez.votingplugin.proxy.presence.PlayerPresence; +import com.bencodez.votingplugin.proxy.redis.VotingPluginRedisChannels; +import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator; +import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator.Domain; +import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator.Mode; +import com.bencodez.votingplugin.proxy.security.TransportEnvelopeEncryption; +import com.bencodez.votingplugin.proxy.security.TransportEnvelopeHttpCodec; import com.bencodez.votingplugin.timequeue.VoteTimeQueue; import com.bencodez.votingplugin.topvoter.TopVoter; import com.bencodez.votingplugin.util.DurableFiles; @@ -292,6 +299,12 @@ private void close() { @Getter private RedisHandler redisHandler; private JedisPool redisPublisherPool; + private volatile SharedTransportEnvelopeAuthenticator sharedTransportAuthenticator; + private volatile TransportEnvelopeEncryption communicationEncryption; + private final Object transportSecurityLock = new Object(); + private final AtomicBoolean communicationEncryptionFailureLogged = new AtomicBoolean(); + private final AtomicBoolean sharedTransportAuthenticationFailureLogged = new AtomicBoolean(); + private final AtomicBoolean sharedTransportCompatibilityWarningLogged = new AtomicBoolean(); private volatile long redisPublisherRetryAfter; private boolean timeVoteRetryScheduled; private boolean timeVoteDeliveryRetryScheduled; @@ -823,16 +836,7 @@ protected boolean sendProxyBroadcastEnvelopeNow(String server, JsonEnvelope enve case MQTT: return sendMqttEnvelopeServer(server, envelope); case MYSQL: - if (proxyMysqlMessenger == null) { - return false; - } - try { - proxyMysqlMessenger.sendToBackend(server, envelope); - return true; - } catch (SQLException e) { - debug(e.getMessage()); - return false; - } + return sendMysqlEnvelopeServer(server, envelope); case PLUGINMESSAGING: return sendPluginMessageServerNow(server, envelope); case REDIS: @@ -1834,6 +1838,11 @@ public void load(IVoteCache jsonStorage, INonVotedPlayersStorage nonVotedCacheJs if (getMethod() == null) { method = BungeeMethod.PLUGINMESSAGING; } + SharedTransportEnvelopeAuthenticator initialAuthenticator = createSharedTransportAuthenticator(method); + sharedTransportAuthenticationFailureLogged.set(false); + sharedTransportCompatibilityWarningLogged.set(false); + communicationEncryptionFailureLogged.set(false); + installTransportSecurity(initialAuthenticator, createCommunicationEncryption()); warnUnsupportedDedicatedVotingProxyMode(); uuidPlayerNameCache = getProxyMySQL().getRowsUUIDNameQuery(); @@ -1964,6 +1973,7 @@ public void onReceiveEnvelope(JsonEnvelope envelope) { rebuildSocketClients(); } else if (method.equals(BungeeMethod.REDIS)) { + sharedTransportAuthenticator(); redisHandler = new RedisHandler(getConfig().getRedisHost(), getConfig().getRedisPort(), getConfig().getRedisUsername(), getConfig().getRedisPassword(), getConfig().getRedisDbIndex(), getConfig().getRedisSsl()) { @@ -1978,19 +1988,22 @@ public void debug(String message) { runAsync(() -> { RedisListener listener = redisHandler.createEnvelopeListener( - getConfig().getRedisPrefix() + "VotingPlugin", - (ch, env) -> globalMessageProxyHandler.onMessage(env)); + VotingPluginRedisChannels.proxy(getConfig().getRedisPrefix()), + (ch, env) -> acceptSharedTransportEnvelope(env, Domain.REDIS_PROXY_BACKEND, ch, + this::dispatchDecryptedGlobalMessage)); redisHandler.loadListener(listener); }); } else if (method.equals(BungeeMethod.MQTT)) { + sharedTransportAuthenticator(); try { mqttHandler = new MqttHandler(new MqttServerComm(getConfig().getMqttClientID(), getConfig().getMqttBrokerURL(), getConfig().getMqttUsername(), getConfig().getMqttPassword()), 2); mqttHandler.subscribeEnvelopes(getConfig().getMqttPrefix() + "votingplugin/servers/proxy", - (topic, env) -> globalMessageProxyHandler.onMessage(env)); + (topic, env) -> acceptSharedTransportEnvelope(env, Domain.MQTT_PROXY_BACKEND, topic, + this::dispatchDecryptedGlobalMessage)); } catch (MqttException e) { e.printStackTrace(); @@ -2003,37 +2016,7 @@ public void debug(String message) { + getVoteCacheVotePartyIncreaseVotesRequired(); votePartyVotes = getVoteCacheCurrentVotePartyVotes(); - globalMessageProxyHandler = new GlobalMessageProxyHandler() { - @Override - public void sendMessage(String server, int delay, JsonEnvelope envelope) { - switch (method) { - case MQTT: - sendMqttEnvelopeServer(server, envelope); - break; - case MYSQL: - try { - proxyMysqlMessenger.sendToBackend(server, envelope); - } catch (SQLException e) { - e.printStackTrace(); - } - break; - case PLUGINMESSAGING: - sendPluginMessageServer(server, delay, envelope); - break; - case REDIS: - sendRedisEnvelopeServer(server, envelope); - break; - case SOCKETS: - sendSocketEnvelope(server, envelope); - break; - case HTTP: - sendGenericHttpEnvelope(server, envelope); - break; - default: - break; - } - } - }; + globalMessageProxyHandler = new VotingPluginGlobalMessageProxyHandler(); registerControlEnrollmentListener(globalMessageProxyHandler); globalMessageProxyHandler.addListener(new GlobalMessageListener(VotingPluginWire.SUB_LOGIN) { @@ -2518,6 +2501,12 @@ public void loadMultiProxySupport() { if (multiProxyHandler != null) { multiProxyHandler.close(); } + // A handler owns one transport-security generation. In particular, callbacks + // retained by a reused Redis connection must not start verifying with a newly + // installed authenticator while they still decrypt with this handler's old + // encryption policy during a soft reload. + SharedTransportEnvelopeAuthenticator multiProxyAuthenticator = getConfig().getMultiProxySupport() + ? sharedTransportAuthenticator() : sharedTransportAuthenticator; multiProxyHandler = new MultiProxyHandler() { @Override @@ -2535,6 +2524,11 @@ public boolean getDebug() { return getConfig().getDebug(); } + @Override + public boolean getCommunicationEncryption() { + return getConfig().getCommunicationEncryption(); + } + @Override public EncryptionHandler getEncryptionHandler() { return encryptionHandler; @@ -2631,6 +2625,16 @@ public RedisHandler getRedisHandler() { return redisHandler; } + @Override + public String getRedisPrefix() { + return getConfig().getRedisPrefix(); + } + + @Override + public SharedTransportEnvelopeAuthenticator getSharedTransportAuthenticator() { + return multiProxyAuthenticator; + } + @Override public String getVersion() { return getPluginVersion(); @@ -3529,12 +3533,14 @@ public void onPluginMessageReceived(DataInputStream in, String sourceServer) { return; } - if (VotingPluginWire.SUB_CONTROL_ENROLLMENT_REQUEST.equals(envelope.getSubChannel())) { - handleControlEnrollmentRequest(sourceServer, envelope); + JsonEnvelope decrypted = decryptCommunicationEnvelope(envelope); + if (decrypted == null) return; + if (VotingPluginWire.SUB_CONTROL_ENROLLMENT_REQUEST.equals(decrypted.getSubChannel())) { + handleControlEnrollmentRequest(sourceServer, decrypted); return; } - globalMessageProxyHandler.onMessage(envelope); + dispatchDecryptedGlobalMessage(decrypted); } catch (Exception e) { e.printStackTrace(); } @@ -3842,7 +3848,14 @@ private void reloadRuntime(boolean restartControlServices) { invalidateDeferredHttpTransportReconciliation(); BungeeMethod configuredMethod = BungeeMethod.getByName(getConfig().getBungeeMethod()); if (configuredMethod == null) configuredMethod = BungeeMethod.PLUGINMESSAGING; + SharedTransportEnvelopeAuthenticator replacementAuthenticator = createSharedTransportAuthenticator( + configuredMethod); + TransportEnvelopeEncryption replacementEncryption = createCommunicationEncryption(); method = retainHttpForPendingDeliveries(configuredMethod); + installTransportSecurity(replacementAuthenticator, replacementEncryption); + sharedTransportAuthenticationFailureLogged.set(false); + sharedTransportCompatibilityWarningLogged.set(false); + warnIfSharedTransportCompatibilityMode(replacementAuthenticator); scheduleDeferredHttpTransportReconciliation(); warnUnsupportedDedicatedVotingProxyMode(); if (!restartControlServices && method == BungeeMethod.SOCKETS) { @@ -3857,6 +3870,30 @@ private void reloadRuntime(boolean restartControlServices) { } } + private TransportEnvelopeEncryption createCommunicationEncryption() { + try { + return TransportEnvelopeEncryption.load(getDataFolderPlugin().toPath().resolve("secretkey.key"), + TransportEnvelopeEncryption.Domain.PROXY_BACKEND, getConfig().getCommunicationEncryption()); + } catch (IOException failure) { + throw new IllegalStateException("Proxy communication encryption initialization failed", failure); + } + } + + private void installTransportSecurity(SharedTransportEnvelopeAuthenticator authenticator, + TransportEnvelopeEncryption encryption) { + synchronized (transportSecurityLock) { + if (sharedTransportAuthenticator == null || authenticator == null + || !sharedTransportAuthenticator.hasEquivalentInboundPolicy(authenticator)) { + sharedTransportAuthenticator = authenticator; + } + if (communicationEncryption == null || encryption == null + || !communicationEncryption.hasEquivalentInboundPolicy(encryption)) { + communicationEncryption = encryption; + } + communicationEncryptionFailureLogged.set(false); + } + } + private synchronized BungeeMethod retainHttpForPendingDeliveries(BungeeMethod configuredMethod) { if (configuredMethod == BungeeMethod.HTTP && httpTransportServer != null && !hasChangedLiveHttpConfiguration()) { @@ -4099,11 +4136,28 @@ private synchronized void rebuildSocketClients() { stopSocketClients(previous); } + private boolean sendMysqlEnvelopeServer(String server, JsonEnvelope envelope) { + if (proxyMysqlMessenger == null) return false; + try { + proxyMysqlMessenger.sendToBackend(server, encryptCommunicationEnvelope(envelope)); + return true; + } catch (SQLException failure) { + debug(failure.getMessage()); + return false; + } + } + + private JsonEnvelope encryptCommunicationEnvelope(JsonEnvelope envelope) { + synchronized (transportSecurityLock) { + return communicationEncryption == null ? envelope : communicationEncryption.encrypt(envelope); + } + } + private synchronized boolean sendSocketEnvelope(String server, JsonEnvelope envelope) { ClientHandler socketClient = clientHandles == null ? null : clientHandles.get(server); if (socketClient == null) return false; try { - socketClient.sendEnvelope(envelope); + socketClient.sendEnvelope(encryptCommunicationEnvelope(envelope)); return true; } catch (RuntimeException e) { debug(e.getMessage()); @@ -4249,7 +4303,7 @@ private void startHttpTransport() { httpTransportServer = new HttpProxyTransportServer( new InetSocketAddress(startup.host, startup.port), identity, httpEnrollmentAuthority, directory.toPath().resolve("outgoing-v1"), - this::handleHttpTransportEnvelope, this::acknowledgeHttpDelivery); + this::handleHttpTransportEnvelope, this::acknowledgeHttpDelivery, httpWireCodec()); httpTransportServer.start(); } persistRetainedHttpListenerSettings(startup); @@ -4267,17 +4321,34 @@ private void startHttpTransport() { /** Keeps the authenticated mTLS backend identity attached to security-sensitive proxy routing. */ protected void handleHttpTransportEnvelope(HttpProxyTransportServer.ReceivedEnvelope received) { - if (!isAuthenticatedHttpEnvelopeAllowed(received)) { + if (received == null) return; + JsonEnvelope decrypted = received.envelope(); + HttpProxyTransportServer.ReceivedEnvelope authenticated = new HttpProxyTransportServer.ReceivedEnvelope( + received.serverId(), received.messageId(), decrypted); + if (!isAuthenticatedHttpEnvelopeAllowed(authenticated)) { debug("Ignored HTTP envelope whose player-presence claim did not match its authenticated backend"); return; } - if (VotingPluginWire.SUB_CONTROL_ENROLLMENT_REQUEST.equals(received.envelope().getSubChannel())) { - handleControlEnrollmentRequest(received.serverId(), received.envelope()); + if (VotingPluginWire.SUB_CONTROL_ENROLLMENT_REQUEST.equals(decrypted.getSubChannel())) { + handleControlEnrollmentRequest(received.serverId(), decrypted); return; } - GlobalMessageProxyHandler handler = globalMessageProxyHandler; - if (handler == null) throw new IllegalStateException("HTTP message router is not ready"); - handler.onMessage(received.envelope()); + dispatchDecryptedGlobalMessage(decrypted); + } + + private JsonEnvelope decryptCommunicationEnvelope(JsonEnvelope envelope) { + synchronized (transportSecurityLock) { + return decryptCommunicationEnvelopeLocked(envelope); + } + } + + private JsonEnvelope decryptCommunicationEnvelopeLocked(JsonEnvelope envelope) { + if (communicationEncryption == null) return envelope; + TransportEnvelopeEncryption.Decryption decrypted = communicationEncryption.decrypt(envelope); + if (decrypted.accepted()) return decrypted.envelope(); + if (communicationEncryptionFailureLogged.compareAndSet(false, true)) logSevere( + "Proxy communication message rejected by encryption policy (" + decrypted.reason() + ")"); + return null; } private boolean isAuthenticatedHttpEnvelopeAllowed(HttpProxyTransportServer.ReceivedEnvelope received) { @@ -4343,7 +4414,16 @@ private PreparedHttpTransport createPreparedHttpTransport(VotingPluginProxyConfi File directory = new File(getDataFolderPlugin(), "http"); HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(directory.toPath(), endpoint.getHost()); HttpEnrollmentAuthority authority = new HttpEnrollmentAuthority(identity, directory.toPath()); + TransportEnvelopeEncryption candidateEncryption = TransportEnvelopeEncryption.load( + getDataFolderPlugin().toPath().resolve("secretkey.key"), + TransportEnvelopeEncryption.Domain.PROXY_BACKEND, candidate.getCommunicationEncryption()); AtomicReference owner = new AtomicReference<>(); + HttpEnvelopeWireCodec wireCodec = new TransportEnvelopeHttpCodec(candidateEncryption, reason -> { + VotingPluginProxy active = owner.get(); + if (active != null && active.communicationEncryptionFailureLogged.compareAndSet(false, true)) { + active.logSevere("Proxy communication message rejected by encryption policy (" + reason + ")"); + } + }); server = new HttpProxyTransportServer( new InetSocketAddress(candidate.getHttpHost(), candidate.getHttpPort()), identity, authority, directory.toPath().resolve("outgoing-v1"), received -> { @@ -4354,7 +4434,7 @@ private PreparedHttpTransport createPreparedHttpTransport(VotingPluginProxyConfi VotingPluginProxy active = owner.get(); if (active == null) throw new IOException("HTTP runtime replacement is not active"); active.acknowledgeHttpDelivery(backend, deliveryId); - }); + }, wireCodec); server.start(); return new PreparedHttpTransport(server, authority, owner, candidate.getHttpHost(), candidate.getHttpPort(), candidate.getHttpPublicEndpoint()); @@ -4364,6 +4444,19 @@ private PreparedHttpTransport createPreparedHttpTransport(VotingPluginProxyConfi } } + private HttpEnvelopeWireCodec httpWireCodec() { + synchronized (transportSecurityLock) { + if (communicationEncryption == null) { + throw new IllegalStateException("HTTP communication encryption policy is unavailable"); + } + return new TransportEnvelopeHttpCodec(communicationEncryption, reason -> { + if (communicationEncryptionFailureLogged.compareAndSet(false, true)) { + logSevere("Proxy communication message rejected by encryption policy (" + reason + ")"); + } + }); + } + } + private Path httpTransportPreparationKey() { return getDataFolderPlugin().toPath().toAbsolutePath().normalize(); } @@ -4584,6 +4677,7 @@ public void sendPluginMessageServer(String server, int delay, JsonEnvelope envel * @return true when the proxy accepted the message for delivery */ protected boolean sendPluginMessageServerNow(String server, JsonEnvelope envelope) { + envelope = encryptCommunicationEnvelope(envelope); final String subChannel = envelope.getSubChannel(); final String payload = JsonEnvelopeCodec.encode(envelope); @@ -4657,6 +4751,127 @@ static DefaultJedisClientConfig buildRedisClientConfig(VotingPluginProxyConfig c return config.build(); } + private SharedTransportEnvelopeAuthenticator sharedTransportAuthenticator() { + SharedTransportEnvelopeAuthenticator authenticator; + synchronized (transportSecurityLock) { + authenticator = sharedTransportAuthenticatorLocked(); + } + warnIfSharedTransportCompatibilityMode(authenticator); + return authenticator; + } + + private SharedTransportEnvelopeAuthenticator sharedTransportAuthenticatorLocked() { + SharedTransportEnvelopeAuthenticator authenticator = sharedTransportAuthenticator; + if (authenticator == null) { + authenticator = createSharedTransportAuthenticator(method); + if (authenticator == null) + throw new IllegalStateException("Shared transport authentication requested without a shared transport"); + sharedTransportAuthenticator = authenticator; + } + return authenticator; + } + + private SharedTransportEnvelopeAuthenticator createSharedTransportAuthenticator(BungeeMethod configuredMethod) { + MultiProxyMethod multiProxyMethod = MultiProxyMethod.getByName(getConfig().getMultiProxyMethod()); + boolean sharedTransportConfigured = configuredMethod == BungeeMethod.REDIS || configuredMethod == BungeeMethod.MQTT + || (getConfig().getMultiProxySupport() && multiProxyMethod == MultiProxyMethod.REDIS); + if (!sharedTransportConfigured) return null; + Mode mode = Mode.parse(getConfig().getSharedTransportAuthentication()); + try { + return SharedTransportEnvelopeAuthenticator.load( + getDataFolderPlugin().toPath().resolve("secretkey.key"), mode); + } catch (IOException authenticationFailure) { + throw new IllegalStateException("Shared Redis/MQTT transport authentication initialization failed", + authenticationFailure); + } + } + + /** Reject invalid replacement transport keys and authentication settings before retiring this runtime. */ + public void validateReplacementTransportSecurity() { + BungeeMethod configuredMethod = BungeeMethod.getByName(getConfig().getBungeeMethod()); + if (configuredMethod == null) configuredMethod = BungeeMethod.PLUGINMESSAGING; + createSharedTransportAuthenticator(configuredMethod); + try { + TransportEnvelopeEncryption.load(getDataFolderPlugin().toPath().resolve("secretkey.key"), + TransportEnvelopeEncryption.Domain.PROXY_BACKEND, getConfig().getCommunicationEncryption()); + } catch (IOException encryptionFailure) { + throw new IllegalStateException("Proxy communication encryption initialization failed", encryptionFailure); + } + } + + private void warnIfSharedTransportCompatibilityMode(SharedTransportEnvelopeAuthenticator authenticator) { + if (authenticator != null && authenticator.mode() == Mode.COMPATIBILITY + && sharedTransportCompatibilityWarningLogged.compareAndSet(false, true)) log( + "WARNING: SharedTransportAuthentication is COMPATIBILITY; unsigned Redis/MQTT messages are accepted during this rolling upgrade"); + } + + void acceptSharedTransportEnvelope(JsonEnvelope envelope, Domain domain, String destination, + java.util.function.Consumer accepted) { + SharedTransportEnvelopeAuthenticator authenticator; + SharedTransportEnvelopeAuthenticator.Verification verification; + JsonEnvelope decrypted; + synchronized (transportSecurityLock) { + authenticator = sharedTransportAuthenticatorLocked(); + verification = authenticator.verify(envelope, domain, destination); + decrypted = verification.accepted() ? decryptCommunicationEnvelopeLocked(verification.envelope()) : null; + } + warnIfSharedTransportCompatibilityMode(authenticator); + if (!verification.accepted()) { + if (sharedTransportAuthenticationFailureLogged.compareAndSet(false, true)) log( + "Shared transport message rejected by envelope authentication (" + verification.rejection() + ")"); + return; + } + if (decrypted != null) accepted.accept(decrypted); + } + + private void dispatchDecryptedGlobalMessage(JsonEnvelope envelope) { + GlobalMessageProxyHandler handler = globalMessageProxyHandler; + if (handler == null) throw new IllegalStateException("Proxy message router is not ready"); + if (handler instanceof VotingPluginGlobalMessageProxyHandler votingPluginHandler) { + votingPluginHandler.onDecryptedMessage(envelope); + } else { + handler.onMessage(envelope); + } + } + + private final class VotingPluginGlobalMessageProxyHandler extends GlobalMessageProxyHandler { + @Override + public void onMessage(JsonEnvelope envelope) { + JsonEnvelope decrypted = decryptCommunicationEnvelope(envelope); + if (decrypted != null) super.onMessage(decrypted); + } + + void onDecryptedMessage(JsonEnvelope envelope) { + super.onMessage(envelope); + } + + @Override + public void sendMessage(String server, int delay, JsonEnvelope envelope) { + switch (method) { + case MQTT: + sendMqttEnvelopeServer(server, envelope); + break; + case MYSQL: + sendMysqlEnvelopeServer(server, envelope); + break; + case PLUGINMESSAGING: + sendPluginMessageServer(server, delay, envelope); + break; + case REDIS: + sendRedisEnvelopeServer(server, envelope); + break; + case SOCKETS: + sendSocketEnvelope(server, envelope); + break; + case HTTP: + sendGenericHttpEnvelope(server, envelope); + break; + default: + break; + } + } + } + public boolean sendRedisEnvelopeServer(String server, JsonEnvelope envelope) { return sendRedisEnvelopeServer(server, envelope, false); } @@ -4668,9 +4883,15 @@ private boolean sendRedisEnvelopeServer(String server, JsonEnvelope envelope, bo } try (Jedis jedis = publisherPool.getResource()) { - String channel = getConfig().getRedisPrefix() + "VotingPlugin_" + server; - long subscribers = jedis.publish(channel, - JsonEnvelopeCodec.encode(VotingPluginWire.withRedisDeliveryId(envelope))); + String channel; + JsonEnvelope authenticated; + synchronized (transportSecurityLock) { + channel = VotingPluginRedisChannels.backend(getConfig().getRedisPrefix(), server); + JsonEnvelope identified = VotingPluginWire.withRedisDeliveryId(encryptCommunicationEnvelope(envelope)); + authenticated = sharedTransportAuthenticatorLocked().sign(identified, Domain.REDIS_PROXY_BACKEND, + getConfig().getProxyServerName(), channel); + } + long subscribers = jedis.publish(channel, JsonEnvelopeCodec.encode(authenticated)); redisPublisherRetryAfter = 0L; return subscribers > 0; } catch (Exception e) { @@ -4688,7 +4909,13 @@ public boolean sendMqttEnvelopeServer(String server, JsonEnvelope envelope) { return false; } try { - mqttHandler.publishEnvelope(getConfig().getMqttPrefix() + "votingplugin/servers/" + server, envelope); + String topic = getConfig().getMqttPrefix() + "votingplugin/servers/" + server; + JsonEnvelope authenticated; + synchronized (transportSecurityLock) { + authenticated = sharedTransportAuthenticatorLocked().sign(encryptCommunicationEnvelope(envelope), + Domain.MQTT_PROXY_BACKEND, getConfig().getProxyServerName(), topic); + } + mqttHandler.publishEnvelope(topic, authenticated); return true; } catch (Exception e) { if (getConfig().getDebug()) { @@ -4709,7 +4936,7 @@ public boolean sendSocketEnvelopeServer(String server, JsonEnvelope envelope) { return false; } - String payload = JsonEnvelopeCodec.encode(envelope); + String payload = JsonEnvelopeCodec.encode(encryptCommunicationEnvelope(envelope)); String encoded = encryptionHandler != null ? encryptionHandler.encrypt(payload) : payload; try (Socket socket = new Socket()) { socket.connect(new InetSocketAddress(host, port), 2000); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxyConfig.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxyConfig.java index cd00c91e0..26ad9e095 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxyConfig.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxyConfig.java @@ -10,6 +10,10 @@ * Configuration interface for proxy server integration. */ public interface VotingPluginProxyConfig { + /** Authentication policy for Redis, MQTT, and multi-proxy Redis envelopes. */ + default String getSharedTransportAuthentication() { + return "COMPATIBILITY"; + } /** Atomically persists the small non-secret configuration domain exposed by Control. */ default void persistControlProxyRouting(boolean sendVotesToAllServers, List blockedServers) throws IOException { @@ -270,6 +274,11 @@ default int getHttpPort() { */ public boolean getPluginMessageEncryption(); + /** Encrypts complete VotingPlugin communication envelopes for every proxy method. */ + default boolean getCommunicationEncryption() { + return false; + } + /** * Gets whether debug mode is enabled. * diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/BungeeConfig.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/BungeeConfig.java index 4aa394cca..937e729a5 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/BungeeConfig.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/BungeeConfig.java @@ -382,6 +382,11 @@ public int getRedisDbIndex() { public String getRedisPrefix() { return getData().getString("Redis.Prefix", ""); } + + @Override + public String getSharedTransportAuthentication() { + return getData().getString("SharedTransportAuthentication", "COMPATIBILITY"); + } @Override public String getRedisUsername() { @@ -710,9 +715,14 @@ public String getPluginMessageChannel() { } @Override - public boolean getPluginMessageEncryption() { - return getData().getBoolean("PluginMessageEncryption", false); - } + public boolean getPluginMessageEncryption() { + return getData().getBoolean("PluginMessageEncryption", false); + } + + @Override + public boolean getCommunicationEncryption() { + return getData().getBoolean("CommunicationEncryption", false); + } @Override public Collection getWaitUntilVoteDelaySites() { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/VotingPluginBungee.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/VotingPluginBungee.java index ab3997349..609cae2b3 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/VotingPluginBungee.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/VotingPluginBungee.java @@ -197,8 +197,9 @@ public void onEnable() { getProxy().getPluginManager().registerListener(this, this); - config = new BungeeConfig(this); - config.load(); + config = new BungeeConfig(this); + config.load(); + ensureCommunicationSecret(); getProxy().getPluginManager().registerCommand(this, new VotingPluginBungeeCommand(this)); @@ -499,6 +500,24 @@ public void reloadPlugin(boolean loadMysql) { } } + private void ensureCommunicationSecret() { + try { + boolean created = com.bencodez.votingplugin.proxy.security.SharedSecretKeyFile + .ensure(getDataFolder().toPath().resolve("secretkey.key")); + if (created) getLogger().info("Created secretkey.key for VotingPlugin communication security"); + if (!config.getCommunicationEncryption()) getLogger().warning( + "CommunicationEncryption is disabled. Copy this proxy's secretkey.key to every VotingPlugin node, enable CommunicationEncryption everywhere, and restart (recommended)."); + } catch (java.io.IOException failure) { + boolean required = config.getCommunicationEncryption() + || com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator.Mode + .parse(config.getSharedTransportAuthentication()) + == com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator.Mode.REQUIRED; + if (required) throw new IllegalStateException( + "Unable to prepare required VotingPlugin communication secretkey.key", failure); + getLogger().warning("Unable to create optional secretkey.key; continuing with legacy plaintext/unsigned communication. Fix the data-folder permissions before enabling communication security."); + } + } + void initializeFirstRuntime() { // Full initialization creates the first runtime; there is no old runtime to retire. reloadPlugin(true); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/multiproxy/MultiProxyHandler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/multiproxy/MultiProxyHandler.java index bb4cb9ad8..9a8366a18 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/multiproxy/MultiProxyHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/multiproxy/MultiProxyHandler.java @@ -4,9 +4,13 @@ import java.io.File; import java.io.IOException; import java.nio.file.Path; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.util.Collection; import java.util.HashSet; import java.util.HashMap; +import java.util.HexFormat; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; @@ -14,9 +18,12 @@ import java.util.Map; import java.util.Set; import java.util.UUID; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import com.bencodez.simpleapi.encryption.EncryptionHandler; import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope; +import com.bencodez.simpleapi.servercomm.codec.JsonEnvelopeCodec; import com.bencodez.simpleapi.servercomm.redis.RedisHandler; import com.bencodez.simpleapi.servercomm.redis.RedisListener; import com.bencodez.simpleapi.servercomm.sockets.ClientHandler; @@ -24,6 +31,10 @@ import com.bencodez.simpleapi.servercomm.sockets.SocketReceiver; import com.bencodez.votingplugin.proxy.VoteTotalsSnapshot; import com.bencodez.votingplugin.proxy.VotingPluginWire; +import com.bencodez.votingplugin.proxy.redis.VotingPluginRedisChannels; +import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator; +import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator.Domain; +import com.bencodez.votingplugin.proxy.security.TransportEnvelopeEncryption; import lombok.Getter; @@ -68,6 +79,13 @@ public abstract class MultiProxyHandler { * operator action (repair/remove the named state file, then restart). */ private boolean voteCapabilityRecoveryBlocked; + private final AtomicBoolean authenticationFailureLogged = new AtomicBoolean(); + private final AtomicBoolean encryptionFailureLogged = new AtomicBoolean(); + private static final int MAX_UNSIGNED_BRIDGE_ENTRIES = 1024; + private static final long UNSIGNED_BRIDGE_WINDOW_NANOS = TimeUnit.SECONDS.toNanos(2); + private final Map unsignedBridgeCopies = new LinkedHashMap<>(); + private TransportEnvelopeEncryption communicationEncryption; + private volatile boolean redisCallbacksActive = true; private long lastVoteCapabilityAdvertisementMillis = Long.MIN_VALUE; /** A newly persisted discovery deadline must cause an initial handshake promptly. */ private boolean voteCapabilityDiscoveryAnnouncementRequired; @@ -106,6 +124,11 @@ long capabilityNowMillis() { * Closes the multi-proxy handler. */ public synchronized void close() { + // RedisHandler owns listeners when the multi-proxy transport reuses the + // global connection. Fence this retired handler before the current + // encryption policy can be replaced so those callbacks cannot accept or + // mutate state after a soft reload. + redisCallbacksActive = false; if (multiproxySocketHandler != null) { multiproxySocketHandler.closeConnection(); multiproxySocketHandler = null; @@ -119,6 +142,7 @@ public synchronized void close() { acknowledgedVoteCapabilityPeers.clear(); knownVoteCapabilityPeers.clear(); voteCapabilityDiscoveryDeadlines.clear(); + unsignedBridgeCopies.clear(); voteCapabilityRecoveryBlocked = false; lastVoteCapabilityAdvertisementMillis = Long.MIN_VALUE; lastVoteCapabilityObservationMillis = 0L; @@ -131,6 +155,11 @@ public synchronized void close() { */ public abstract boolean getDebug(); + /** Whether complete multi-proxy envelopes require optional authenticated encryption. */ + public boolean getCommunicationEncryption() { + return false; + } + /** * Gets the encryption handler. * @@ -265,6 +294,12 @@ public synchronized void close() { */ public abstract RedisHandler getRedisHandler(); + /** Configured namespace shared with the ordinary VotingPlugin Redis transport. */ + public abstract String getRedisPrefix(); + + /** Authenticator shared by all broker messages owned by this proxy runtime. */ + public abstract SharedTransportEnvelopeAuthenticator getSharedTransportAuthenticator(); + /** * Gets the version. * @@ -626,6 +661,7 @@ private Path capabilityStateDirectory() { * Loads multi-proxy support. */ public synchronized void loadMultiProxySupport() { + redisCallbacksActive = false; acknowledgedVoteCapabilityPeers.clear(); knownVoteCapabilityPeers.clear(); voteCapabilityDiscoveryDeadlines.clear(); @@ -637,6 +673,16 @@ public synchronized void loadMultiProxySupport() { if (!getMultiProxySupportEnabled()) { return; } + File dataFolder = getPluginDataFolder(); + if (dataFolder != null) try { + communicationEncryption = TransportEnvelopeEncryption.load(dataFolder.toPath().resolve("secretkey.key"), + TransportEnvelopeEncryption.Domain.MULTI_PROXY, getCommunicationEncryption()); + } catch (IOException failure) { + throw new IllegalStateException("Multi-proxy communication encryption initialization failed", failure); + } else if (getCommunicationEncryption()) { + throw new IllegalStateException("Multi-proxy communication encryption requires a plugin data folder"); + } + encryptionFailureLogged.set(false); if (getMultiProxyMethod().equals(MultiProxyMethod.SOCKETS)) { if (getEncryptionHandler() == null) { @@ -661,7 +707,7 @@ public void log(String str) { multiproxySocketHandler.add(new SocketReceiver() { @Override public void onReceiveEnvelope(JsonEnvelope envelope) { - handleEnvelope(envelope); + acceptEncryptedEnvelope(envelope); } }); @@ -673,6 +719,8 @@ public void onReceiveEnvelope(JsonEnvelope envelope) { } } else { + if (getSharedTransportAuthenticator() == null) + throw new IllegalStateException("Multi-proxy Redis authentication is unavailable"); if (getMultiProxyRedisUseExistingConnection() && getRedisHandler() != null) { multiProxyRedis = getRedisHandler(); } else { @@ -688,10 +736,13 @@ public void debug(String message) { }; } + redisCallbacksActive = true; runAsnc(() -> { - RedisListener listener = multiProxyRedis.createEnvelopeListener( - "VotingPluginProxy_" + getMultiProxyServerName(), (ch, env) -> handleEnvelope(env)); - multiProxyRedis.loadListener(listener); + loadMultiProxyRedisListener( + VotingPluginRedisChannels.multiProxy(getRedisPrefix(), getMultiProxyServerName())); + if (useLegacyMultiProxyRedisChannel()) { + loadMultiProxyRedisListener(VotingPluginRedisChannels.multiProxy("", getMultiProxyServerName())); + } }); } @@ -791,7 +842,7 @@ public synchronized boolean sendMultiProxyEnvelopeAccepted(JsonEnvelope envelope } destinations++; try { - h.sendEnvelope(envelope); + h.sendEnvelope(encryptEnvelope(envelope)); } catch (RuntimeException failure) { accepted = false; } @@ -799,12 +850,23 @@ public synchronized boolean sendMultiProxyEnvelopeAccepted(JsonEnvelope envelope return accepted && destinations == requested.size(); } else if (getMultiProxyMethod().equals(MultiProxyMethod.REDIS)) { if (multiProxyRedis == null) return false; + SharedTransportEnvelopeAuthenticator authenticator = getSharedTransportAuthenticator(); + if (authenticator == null) return false; boolean accepted = true; int destinations = 0; for (String server : requested.values()) { destinations++; try { - multiProxyRedis.publishEnvelope("VotingPluginProxy_" + server, envelope); + JsonEnvelope encrypted = encryptEnvelope(envelope); + String channel = VotingPluginRedisChannels.multiProxy(getRedisPrefix(), server); + JsonEnvelope signed = authenticator.sign(encrypted, Domain.REDIS_MULTI_PROXY, + getMultiProxyServerName(), channel); + multiProxyRedis.publishEnvelope(channel, signed); + if (useLegacyMultiProxyRedisChannel()) { + // Publish the identical envelope. An upgraded peer subscribed to both + // channels rejects the second copy through the replay fence. + multiProxyRedis.publishEnvelope(VotingPluginRedisChannels.multiProxy("", server), signed); + } } catch (RuntimeException failure) { accepted = false; } @@ -814,6 +876,134 @@ public synchronized boolean sendMultiProxyEnvelopeAccepted(JsonEnvelope envelope return false; } + private boolean useLegacyMultiProxyRedisChannel() { + SharedTransportEnvelopeAuthenticator authenticator = getSharedTransportAuthenticator(); + return authenticator != null && authenticator.mode() == SharedTransportEnvelopeAuthenticator.Mode.COMPATIBILITY + && getRedisPrefix() != null && !getRedisPrefix().isEmpty(); + } + + private void loadMultiProxyRedisListener(String channel) { + RedisListener listener = multiProxyRedis.createEnvelopeListener(channel, + (ch, env) -> acceptRedisEnvelope(env, ch)); + multiProxyRedis.loadListener(listener); + } + + void acceptRedisEnvelope(JsonEnvelope envelope) { + acceptRedisEnvelope(envelope, null); + } + + synchronized void acceptRedisEnvelope(JsonEnvelope envelope, String channel) { + if (!redisCallbacksActive) return; + SharedTransportEnvelopeAuthenticator authenticator = getSharedTransportAuthenticator(); + if (authenticator == null) return; + SharedTransportEnvelopeAuthenticator.Verification verification = authenticator.verify(envelope, + Domain.REDIS_MULTI_PROXY, channel); + if (!verification.accepted()) { + if (authenticationFailureLogged.compareAndSet(false, true)) { + logInfo("Multi-proxy Redis message rejected by envelope authentication (" + verification.rejection() + + ")"); + } + return; + } + JsonEnvelope decrypted = decryptEnvelope(verification.envelope()); + if (decrypted == null) return; + if (verification.unsignedCompatibility() && suppressUnsignedBridgeCopy(decrypted, channel)) return; + try { + handleEnvelope(decrypted); + } finally { + if (verification.unsignedCompatibility()) extendUnsignedBridgeWindow(decrypted, channel); + } + } + + private void acceptEncryptedEnvelope(JsonEnvelope envelope) { + JsonEnvelope decrypted = decryptEnvelope(envelope); + if (decrypted != null) handleEnvelope(decrypted); + } + + private JsonEnvelope decryptEnvelope(JsonEnvelope envelope) { + if (communicationEncryption == null) return envelope; + TransportEnvelopeEncryption.Decryption decrypted = communicationEncryption.decrypt(envelope); + if (!decrypted.accepted()) { + if (encryptionFailureLogged.compareAndSet(false, true)) logInfo( + "Multi-proxy message rejected by encryption policy (" + decrypted.reason() + ")"); + return null; + } + return decrypted.envelope(); + } + + private synchronized boolean suppressUnsignedBridgeCopy(JsonEnvelope envelope, String channel) { + // Only origin-bound reliable votes own a receiver-side durable replay fence. + // Legacy vote envelopes may carry an ID that their trigger path ignores. + if (!useLegacyMultiProxyRedisChannel() || channel == null + || hasOriginBoundReliableVoteIdentity(envelope)) return false; + String prefixed = VotingPluginRedisChannels.multiProxy(getRedisPrefix(), getMultiProxyServerName()); + String legacy = VotingPluginRedisChannels.multiProxy("", getMultiProxyServerName()); + boolean onPrefixed = channel.equals(prefixed); + if (!onPrefixed && !channel.equals(legacy)) return false; + String fingerprint = unsignedBridgeFingerprint(envelope); + long now = unsignedBridgeNowNanos(); + UnsignedBridgeCopies copies = unsignedBridgeCopies.get(fingerprint); + if (copies == null || copies.expiresAtNanos <= now) { + if (unsignedBridgeCopies.size() >= MAX_UNSIGNED_BRIDGE_ENTRIES) + unsignedBridgeCopies.remove(unsignedBridgeCopies.keySet().iterator().next()); + copies = new UnsignedBridgeCopies(now + UNSIGNED_BRIDGE_WINDOW_NANOS); + unsignedBridgeCopies.put(fingerprint, copies); + } + // Count copies per channel so two identical legitimate publications on the + // same channel still run twice, even when both bridge copies arrive later. + boolean suppress = onPrefixed ? ++copies.prefixed <= copies.legacy : ++copies.legacy <= copies.prefixed; + return suppress; + } + + private synchronized void extendUnsignedBridgeWindow(JsonEnvelope envelope, String channel) { + if (!useLegacyMultiProxyRedisChannel() || channel == null + || hasOriginBoundReliableVoteIdentity(envelope)) return; + String fingerprint = unsignedBridgeFingerprint(envelope); + UnsignedBridgeCopies copies = unsignedBridgeCopies.get(fingerprint); + if (copies != null) copies.expiresAtNanos = unsignedBridgeNowNanos() + UNSIGNED_BRIDGE_WINDOW_NANOS; + } + + private static boolean hasOriginBoundReliableVoteIdentity(JsonEnvelope envelope) { + String subChannel = envelope.getSubChannel(); + if (!VotingPluginWire.SUB_VOTE.equals(subChannel) + && !VotingPluginWire.SUB_VOTE_ONLINE.equals(subChannel)) return false; + String origin = envelope.getFields().get(VotingPluginWire.K_MULTI_PROXY_ORIGIN); + if (origin == null || origin.isBlank()) return false; + try { + UUID.fromString(envelope.getFields().get(VotingPluginWire.K_VOTE_ID)); + return true; + } catch (RuntimeException invalidVoteId) { + return false; + } + } + + long unsignedBridgeNowNanos() { + return System.nanoTime(); + } + + private static String unsignedBridgeFingerprint(JsonEnvelope envelope) { + try { + byte[] bytes = JsonEnvelopeCodec.encode(envelope).getBytes(StandardCharsets.UTF_8); + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); + } catch (NoSuchAlgorithmException impossible) { + throw new IllegalStateException("SHA-256 is unavailable", impossible); + } + } + + private static final class UnsignedBridgeCopies { + private long expiresAtNanos; + private int prefixed; + private int legacy; + + private UnsignedBridgeCopies(long expiresAtNanos) { + this.expiresAtNanos = expiresAtNanos; + } + } + + private JsonEnvelope encryptEnvelope(JsonEnvelope envelope) { + return communicationEncryption == null ? envelope : communicationEncryption.encrypt(envelope); + } + static void stopSocketClients(Map clients) { if (clients == null) return; for (ClientHandler client : clients.values()) { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/redis/VotingPluginRedisChannels.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/redis/VotingPluginRedisChannels.java new file mode 100644 index 000000000..ed2afe649 --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/redis/VotingPluginRedisChannels.java @@ -0,0 +1,23 @@ +package com.bencodez.votingplugin.proxy.redis; + +/** Canonical Redis channel names for one configured VotingPlugin namespace. */ +public final class VotingPluginRedisChannels { + private VotingPluginRedisChannels() { + } + + public static String proxy(String prefix) { + return prefix(prefix) + "VotingPlugin"; + } + + public static String backend(String prefix, String server) { + return prefix(prefix) + "VotingPlugin_" + server; + } + + public static String multiProxy(String prefix, String proxyServer) { + return prefix(prefix) + "VotingPluginProxy_" + proxyServer; + } + + private static String prefix(String prefix) { + return prefix == null ? "" : prefix; + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/security/SharedSecretKeyFile.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/security/SharedSecretKeyFile.java new file mode 100644 index 000000000..1cea72c6c --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/security/SharedSecretKeyFile.java @@ -0,0 +1,54 @@ +package com.bencodez.votingplugin.proxy.security; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermission; +import java.nio.file.attribute.PosixFilePermissions; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.EnumSet; +import java.util.Set; + +/** Creates the shared transport key without ever replacing an operator-provided key. */ +public final class SharedSecretKeyFile { + private static final int KEY_BYTES = 32; + + private SharedSecretKeyFile() { + } + + public static boolean ensure(Path keyFile) throws IOException { + if (Files.isRegularFile(keyFile)) return false; + Path parent = keyFile.toAbsolutePath().getParent(); + if (parent != null) Files.createDirectories(parent); + byte[] key = new byte[KEY_BYTES]; + new SecureRandom().nextBytes(key); + byte[] encoded = Base64.getEncoder().encode(key); + boolean created = false; + try { + Set options = Set.of(java.nio.file.StandardOpenOption.CREATE_NEW, + java.nio.file.StandardOpenOption.WRITE); + java.nio.channels.SeekableByteChannel opened; + if (Files.getFileStore(parent).supportsFileAttributeView("posix")) { + opened = Files.newByteChannel(keyFile, options, PosixFilePermissions.asFileAttribute( + EnumSet.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE))); + } else { + opened = Files.newByteChannel(keyFile, options); + } + created = true; + try (java.nio.channels.SeekableByteChannel channel = opened) { + java.nio.ByteBuffer buffer = java.nio.ByteBuffer.wrap(encoded); + while (buffer.hasRemaining()) channel.write(buffer); + } + return true; + } catch (java.nio.file.FileAlreadyExistsException raced) { + return false; + } catch (IOException failure) { + if (created) Files.deleteIfExists(keyFile); + throw failure; + } finally { + java.util.Arrays.fill(encoded, (byte) 0); + java.util.Arrays.fill(key, (byte) 0); + } + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/security/SharedTransportEnvelopeAuthenticator.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/security/SharedTransportEnvelopeAuthenticator.java new file mode 100644 index 000000000..be33589f6 --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/security/SharedTransportEnvelopeAuthenticator.java @@ -0,0 +1,328 @@ +package com.bencodez.votingplugin.proxy.security; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.time.Clock; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Comparator; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.PriorityQueue; +import java.util.UUID; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope; + +/** Authenticates messages carried by shared broker transports. */ +public final class SharedTransportEnvelopeAuthenticator { + public enum Domain { + REDIS_PROXY_BACKEND("votingplugin-shared-redis-proxy-backend-envelope-v1"), + MQTT_PROXY_BACKEND("votingplugin-shared-mqtt-proxy-backend-envelope-v1"), + REDIS_MULTI_PROXY("votingplugin-shared-redis-multi-proxy-envelope-v1"); + + private final String value; + + Domain(String value) { + this.value = value; + } + } + + public enum Mode { + REQUIRED, + COMPATIBILITY; + + public static Mode parse(String configured) { + if (configured == null || configured.isBlank() + || "COMPATIBILITY".equalsIgnoreCase(configured.trim())) return COMPATIBILITY; + if ("REQUIRED".equalsIgnoreCase(configured.trim())) return REQUIRED; + throw new IllegalArgumentException( + "SharedTransportAuthentication must be COMPATIBILITY or REQUIRED"); + } + } + + public enum Rejection { + NONE, + MISSING, + MALFORMED, + INVALID, + STALE, + REPLAY, + CAPACITY + } + + public record Verification(boolean accepted, boolean unsignedCompatibility, Rejection rejection, + JsonEnvelope envelope) { + private static Verification accepted(JsonEnvelope envelope, boolean unsignedCompatibility) { + return new Verification(true, unsignedCompatibility, Rejection.NONE, envelope); + } + + private static Verification rejected(Rejection rejection) { + return new Verification(false, false, rejection, null); + } + } + + public static final String K_VERSION = "_vpAuthVersion"; + public static final String K_SENDER = "_vpAuthSender"; + public static final String K_TIMESTAMP = "_vpAuthTimestamp"; + public static final String K_MESSAGE_ID = "_vpAuthMessageId"; + public static final String K_MAC = "_vpAuthMac"; + // Four minutes is the longest possible retention for a message signed at the + // positive skew boundary. This capacity sustains more than 270 messages/second + // for that entire worst-case window without weakening replay rejection. + static final int MAX_REPLAY_ENTRIES = 65_536; + static final long MAX_CLOCK_SKEW_MILLIS = 2 * 60 * 1000L; + private static final String ALGORITHM = "HmacSHA256"; + private static final String DERIVATION_DOMAIN = "votingplugin-shared-transport-key-v1"; + private static final String VERSION = "2"; + private static final String LEGACY_VERSION = "1"; + + private final Map domainKeys; + private final Mode mode; + private final Clock clock; + private final int maxReplayEntries; + private final Map acceptedMessages = new HashMap<>(); + private final PriorityQueue expiryOrder = new PriorityQueue<>( + Comparator.comparingLong(ReplayEntry::expiresAt)); + + private record ReplayEntry(UUID messageId, long expiresAt) { + } + + private SharedTransportEnvelopeAuthenticator(byte[] masterKey, Mode mode, Clock clock) { + this(masterKey, mode, clock, MAX_REPLAY_ENTRIES); + } + + private SharedTransportEnvelopeAuthenticator(byte[] masterKey, Mode mode, Clock clock, int maxReplayEntries) { + domainKeys = new EnumMap<>(Domain.class); + if (masterKey != null) { + for (Domain domain : Domain.values()) domainKeys.put(domain, deriveKey(masterKey, domain)); + } + this.mode = mode; + this.clock = clock; + this.maxReplayEntries = maxReplayEntries; + } + + public static SharedTransportEnvelopeAuthenticator load(Path keyFile, Mode mode) throws IOException { + if (!Files.isRegularFile(keyFile)) { + if (mode == Mode.COMPATIBILITY) + return new SharedTransportEnvelopeAuthenticator(null, mode, Clock.systemUTC()); + throw new IOException("Shared Redis/MQTT transport authentication requires a shared secretkey.key"); + } + try { + byte[] decoded = Base64.getDecoder().decode(Files.readString(keyFile, StandardCharsets.US_ASCII).trim()); + if (decoded.length < 16) throw new IOException("Shared transport authentication key is too short"); + return new SharedTransportEnvelopeAuthenticator(decoded, mode, Clock.systemUTC()); + } catch (IllegalArgumentException invalid) { + if (mode == Mode.COMPATIBILITY) + return new SharedTransportEnvelopeAuthenticator(null, mode, Clock.systemUTC()); + throw new IOException("Shared transport authentication key is invalid", invalid); + } catch (IOException unavailable) { + if (mode == Mode.COMPATIBILITY) + return new SharedTransportEnvelopeAuthenticator(null, mode, Clock.systemUTC()); + throw unavailable; + } + } + + static SharedTransportEnvelopeAuthenticator forTesting(byte[] key, Mode mode, Clock clock) { + return new SharedTransportEnvelopeAuthenticator(key, mode, clock); + } + + static SharedTransportEnvelopeAuthenticator forTesting(byte[] key, Mode mode, Clock clock, + int maxReplayEntries) { + return new SharedTransportEnvelopeAuthenticator(key, mode, clock, maxReplayEntries); + } + + public Mode mode() { + return mode; + } + + /** Returns whether another instance accepts the same authenticated wire policy. */ + public boolean hasEquivalentInboundPolicy(SharedTransportEnvelopeAuthenticator other) { + if (other == null || mode != other.mode || domainKeys.size() != other.domainKeys.size()) return false; + for (Domain domain : Domain.values()) { + byte[] key = domainKeys.get(domain); + byte[] otherKey = other.domainKeys.get(domain); + if (key == null ? otherKey != null : otherKey == null || !MessageDigest.isEqual(key, otherKey)) return false; + } + return true; + } + + public JsonEnvelope sign(JsonEnvelope envelope, Domain domain, String sender, String destination) { + if (envelope == null) throw new IllegalArgumentException("envelope is required"); + Objects.requireNonNull(domain, "domain"); + // Compatibility traffic remains indistinguishable from a legacy sender. Signing + // with a node-local key during a rolling upgrade would make upgraded peers reject + // one another as soon as both happened to have generated different keys. + if (mode == Mode.COMPATIBILITY) return envelope; + if (sender == null || sender.isBlank() || sender.length() > 128) + throw new IllegalArgumentException("shared transport sender identity is invalid"); + if (destination == null || destination.isBlank()) + throw new IllegalArgumentException("shared transport destination is invalid"); + if (domainKeys.isEmpty()) { + throw new IllegalStateException("Shared transport authentication key is unavailable"); + } + long timestamp = clock.millis(); + String messageId = UUID.randomUUID().toString(); + JsonEnvelope unsigned = withAuthenticationFields(envelope, sender, timestamp, messageId, null); + String mac = calculateMac(unsigned, domain, destination, true); + return withAuthenticationFields(envelope, sender, timestamp, messageId, mac); + } + + public synchronized Verification verify(JsonEnvelope envelope, Domain domain, String destination) { + if (envelope == null) return Verification.rejected(Rejection.MALFORMED); + Objects.requireNonNull(domain, "domain"); + Map fields = envelope.getFields(); + boolean anyAuthenticationField = fields.containsKey(K_VERSION) || fields.containsKey(K_SENDER) + || fields.containsKey(K_TIMESTAMP) || fields.containsKey(K_MESSAGE_ID) || fields.containsKey(K_MAC); + if (!anyAuthenticationField) { + if (mode == Mode.COMPATIBILITY) return Verification.accepted(envelope, true); + return Verification.rejected(Rejection.MISSING); + } + String version = fields.get(K_VERSION); + boolean destinationBound = VERSION.equals(version); + if (domainKeys.isEmpty() || (!destinationBound + && !(mode == Mode.COMPATIBILITY && LEGACY_VERSION.equals(version))) + || (destinationBound && (destination == null || destination.isBlank()))) + return Verification.rejected(Rejection.MALFORMED); + String sender = fields.get(K_SENDER); + String messageId = fields.get(K_MESSAGE_ID); + String suppliedMac = fields.get(K_MAC); + long timestamp; + UUID parsedMessageId; + try { + timestamp = Long.parseLong(fields.get(K_TIMESTAMP)); + parsedMessageId = UUID.fromString(messageId); + } catch (RuntimeException malformed) { + return Verification.rejected(Rejection.MALFORMED); + } + if (sender == null || sender.isBlank() || sender.length() > 128 || suppliedMac == null + || !suppliedMac.matches("[0-9a-f]{64}")) + return Verification.rejected(Rejection.MALFORMED); + + String expected = calculateMac(withoutMac(envelope), domain, destination, destinationBound); + if (!MessageDigest.isEqual(expected.getBytes(StandardCharsets.US_ASCII), + suppliedMac.getBytes(StandardCharsets.US_ASCII))) return Verification.rejected(Rejection.INVALID); + + long now = clock.millis(); + if (timestamp < now - MAX_CLOCK_SKEW_MILLIS || timestamp > now + MAX_CLOCK_SKEW_MILLIS) + return Verification.rejected(Rejection.STALE); + pruneExpired(now); + if (acceptedMessages.containsKey(parsedMessageId)) return Verification.rejected(Rejection.REPLAY); + if (acceptedMessages.size() >= maxReplayEntries) return Verification.rejected(Rejection.CAPACITY); + // Retain the nonce for the envelope's complete acceptance window. A sender may + // legitimately be ahead by MAX_CLOCK_SKEW_MILLIS, so retention measured from + // local acceptance time would otherwise leave a second replay window. + long expiresAt = replayExpiry(timestamp); + acceptedMessages.put(parsedMessageId, expiresAt); + expiryOrder.add(new ReplayEntry(parsedMessageId, expiresAt)); + return Verification.accepted(stripAuthenticationFields(envelope), false); + } + + public synchronized int replayEntryCount() { + return acceptedMessages.size(); + } + + // Return the number of heap heads inspected so expiry cost can be asserted + // without a timing-sensitive performance test. + synchronized int pruneExpired(long now) { + int inspected = 0; + while (true) { + inspected++; + ReplayEntry next = expiryOrder.peek(); + if (next == null || next.expiresAt() >= now) return inspected; + ReplayEntry expired = expiryOrder.remove(); + acceptedMessages.remove(expired.messageId(), expired.expiresAt()); + } + } + + private static long replayExpiry(long timestamp) { + if (timestamp > Long.MAX_VALUE - MAX_CLOCK_SKEW_MILLIS) return Long.MAX_VALUE; + return timestamp + MAX_CLOCK_SKEW_MILLIS; + } + + private String calculateMac(JsonEnvelope envelope, Domain domain, String destination, boolean destinationBound) { + try { + Mac mac = Mac.getInstance(ALGORITHM); + mac.init(new SecretKeySpec(domainKeys.get(domain), ALGORITHM)); + update(mac, domain.value); + if (destinationBound) update(mac, destination); + update(mac, Integer.toString(envelope.getSchema())); + update(mac, envelope.getFields().get(K_SENDER)); + update(mac, envelope.getSubChannel()); + update(mac, envelope.getFields().get(K_TIMESTAMP)); + update(mac, envelope.getFields().get(K_MESSAGE_ID)); + List> fields = new ArrayList<>(envelope.getFields().entrySet()); + fields.removeIf(entry -> isAuthenticationField(entry.getKey())); + fields.sort(Comparator.comparing(Map.Entry::getKey)); + for (Map.Entry entry : fields) { + update(mac, entry.getKey()); + update(mac, entry.getValue()); + } + return HexFormat.of().formatHex(mac.doFinal()); + } catch (GeneralSecurityException impossible) { + throw new IllegalStateException("HMAC-SHA-256 is unavailable", impossible); + } + } + + private static byte[] deriveKey(byte[] masterKey, Domain domain) { + try { + Mac derivation = Mac.getInstance(ALGORITHM); + derivation.init(new SecretKeySpec(masterKey, ALGORITHM)); + update(derivation, DERIVATION_DOMAIN); + update(derivation, domain.value); + return derivation.doFinal(); + } catch (GeneralSecurityException impossible) { + throw new IllegalStateException("HMAC-SHA-256 is unavailable", impossible); + } + } + + private static JsonEnvelope withAuthenticationFields(JsonEnvelope envelope, String sender, long timestamp, + String messageId, String mac) { + JsonEnvelope.Builder builder = copyWithoutAuthenticationFields(envelope).toBuilder().put(K_VERSION, VERSION) + .put(K_SENDER, sender == null ? "" : sender).put(K_TIMESTAMP, timestamp).put(K_MESSAGE_ID, messageId); + if (mac != null) builder.put(K_MAC, mac); + return builder.build(); + } + + private static JsonEnvelope withoutMac(JsonEnvelope envelope) { + JsonEnvelope.Builder builder = JsonEnvelope.builder(envelope.getSubChannel()).schema(envelope.getSchema()); + for (Map.Entry entry : envelope.getFields().entrySet()) { + if (!K_MAC.equals(entry.getKey())) builder.put(entry.getKey(), entry.getValue()); + } + return builder.build(); + } + + private static JsonEnvelope stripAuthenticationFields(JsonEnvelope envelope) { + return copyWithoutAuthenticationFields(envelope); + } + + private static JsonEnvelope copyWithoutAuthenticationFields(JsonEnvelope envelope) { + JsonEnvelope.Builder builder = JsonEnvelope.builder(envelope.getSubChannel()).schema(envelope.getSchema()); + for (Map.Entry entry : envelope.getFields().entrySet()) { + if (!isAuthenticationField(entry.getKey())) builder.put(entry.getKey(), entry.getValue()); + } + return builder.build(); + } + + private static boolean isAuthenticationField(String key) { + return K_VERSION.equals(key) || K_SENDER.equals(key) || K_TIMESTAMP.equals(key) + || K_MESSAGE_ID.equals(key) || K_MAC.equals(key); + } + + private static void update(Mac mac, String value) { + byte[] bytes = value == null ? new byte[0] : value.getBytes(StandardCharsets.UTF_8); + mac.update(ByteBuffer.allocate(Integer.BYTES).putInt(bytes.length).array()); + mac.update(bytes); + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/security/TransportEnvelopeEncryption.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/security/TransportEnvelopeEncryption.java new file mode 100644 index 000000000..3e1ea0c05 --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/security/TransportEnvelopeEncryption.java @@ -0,0 +1,171 @@ +package com.bencodez.votingplugin.proxy.security; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.Base64; + +import javax.crypto.Cipher; +import javax.crypto.Mac; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope; +import com.bencodez.simpleapi.servercomm.codec.JsonEnvelopeCodec; + +/** Optional authenticated encryption for complete VotingPlugin transport envelopes. */ +public final class TransportEnvelopeEncryption { + public enum Domain { + PROXY_BACKEND("votingplugin-proxy-backend-encryption-v1"), + MULTI_PROXY("votingplugin-multi-proxy-encryption-v1"); + + private final String value; + + Domain(String value) { + this.value = value; + } + } + + public record Decryption(boolean accepted, JsonEnvelope envelope, String reason) { + private static Decryption accept(JsonEnvelope envelope) { + return new Decryption(true, envelope, null); + } + + private static Decryption reject(String reason) { + return new Decryption(false, null, reason); + } + } + + static final String SUBCHANNEL = "_vpEncrypted"; + private static final String K_VERSION = "version"; + private static final String K_DOMAIN = "domain"; + private static final String K_NONCE = "nonce"; + private static final String K_CIPHERTEXT = "ciphertext"; + private static final String VERSION = "1"; + private static final String DERIVATION_DOMAIN = "votingplugin-transport-encryption-key-v1"; + private static final int NONCE_BYTES = 12; + private static final int TAG_BITS = 128; + private static final int MAX_ENCODED_BYTES = 1024 * 1024; + + private final SecretKeySpec key; + private final Domain domain; + private final boolean enabled; + private final SecureRandom random; + + private TransportEnvelopeEncryption(byte[] masterKey, Domain domain, boolean enabled, SecureRandom random) { + this.key = masterKey == null ? null : new SecretKeySpec(deriveKey(masterKey, domain), "AES"); + this.domain = domain; + this.enabled = enabled; + this.random = random; + } + + public static TransportEnvelopeEncryption load(Path keyFile, Domain domain, boolean enabled) throws IOException { + byte[] decoded = null; + try { + if (!Files.isRegularFile(keyFile)) { + if (!enabled) return new TransportEnvelopeEncryption(null, domain, false, new SecureRandom()); + throw new IOException("Transport encryption requires secretkey.key"); + } + decoded = Base64.getDecoder().decode(Files.readString(keyFile, StandardCharsets.US_ASCII).trim()); + if (decoded.length < 16) throw new IOException("Transport encryption key is too short"); + return new TransportEnvelopeEncryption(decoded, domain, enabled, new SecureRandom()); + } catch (IllegalArgumentException invalid) { + if (!enabled) return new TransportEnvelopeEncryption(null, domain, false, new SecureRandom()); + throw new IOException("Transport encryption key is invalid", invalid); + } catch (IOException unavailable) { + if (!enabled) return new TransportEnvelopeEncryption(null, domain, false, new SecureRandom()); + throw unavailable; + } finally { + if (decoded != null) java.util.Arrays.fill(decoded, (byte) 0); + } + } + + /** Plaintext compatibility policy when no shared key is provisioned. */ + public static TransportEnvelopeEncryption disabled(Domain domain) { + return new TransportEnvelopeEncryption(null, domain, false, new SecureRandom()); + } + + static TransportEnvelopeEncryption forTesting(byte[] key, Domain domain, boolean enabled) { + return new TransportEnvelopeEncryption(key, domain, enabled, new SecureRandom()); + } + + public JsonEnvelope encrypt(JsonEnvelope envelope) { + if (!enabled) return envelope; + try { + byte[] plaintext = JsonEnvelopeCodec.encode(envelope).getBytes(StandardCharsets.UTF_8); + if (plaintext.length > MAX_ENCODED_BYTES) throw new IllegalArgumentException("Transport envelope is too large"); + byte[] nonce = new byte[NONCE_BYTES]; + random.nextBytes(nonce); + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, nonce)); + cipher.updateAAD(aad()); + byte[] ciphertext = cipher.doFinal(plaintext); + return JsonEnvelope.builder(SUBCHANNEL).put(K_VERSION, VERSION).put(K_DOMAIN, domain.value) + .put(K_NONCE, Base64.getEncoder().encodeToString(nonce)) + .put(K_CIPHERTEXT, Base64.getEncoder().encodeToString(ciphertext)).build(); + } catch (GeneralSecurityException failure) { + throw new IllegalStateException("Transport envelope encryption failed", failure); + } + } + + /** Accept encrypted input during rollout even before this node enables outbound encryption. */ + public Decryption decrypt(JsonEnvelope envelope) { + if (envelope == null) return Decryption.reject("missing envelope"); + if (!SUBCHANNEL.equals(envelope.getSubChannel())) { + return enabled ? Decryption.reject("unencrypted envelope") : Decryption.accept(envelope); + } + if (key == null) return Decryption.reject("encryption key unavailable"); + try { + if (!VERSION.equals(envelope.getFields().get(K_VERSION)) + || !domain.value.equals(envelope.getFields().get(K_DOMAIN))) return Decryption.reject("invalid metadata"); + String encodedNonce = envelope.getFields().get(K_NONCE); + String encodedCiphertext = envelope.getFields().get(K_CIPHERTEXT); + if (encodedNonce == null || encodedNonce.length() > 32 || encodedCiphertext == null + || encodedCiphertext.length() > ((MAX_ENCODED_BYTES + 32) * 4L / 3L) + 8L) + return Decryption.reject("invalid encrypted envelope size"); + byte[] nonce = Base64.getDecoder().decode(encodedNonce); + byte[] ciphertext = Base64.getDecoder().decode(encodedCiphertext); + if (nonce.length != NONCE_BYTES || ciphertext.length > MAX_ENCODED_BYTES + 32) + return Decryption.reject("invalid encrypted envelope size"); + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, nonce)); + cipher.updateAAD(aad()); + byte[] plaintext = cipher.doFinal(ciphertext); + if (plaintext.length > MAX_ENCODED_BYTES) return Decryption.reject("decrypted envelope is too large"); + return Decryption.accept(JsonEnvelopeCodec.decode(new String(plaintext, StandardCharsets.UTF_8))); + } catch (RuntimeException | GeneralSecurityException failure) { + return Decryption.reject("authentication failed"); + } + } + + public boolean enabled() { + return enabled; + } + + /** Returns whether another instance enforces the exact same inbound policy and key. */ + public boolean hasEquivalentInboundPolicy(TransportEnvelopeEncryption other) { + if (other == null || enabled != other.enabled || domain != other.domain) return false; + if (key == null || other.key == null) return key == other.key; + return MessageDigest.isEqual(key.getEncoded(), other.key.getEncoded()); + } + + private byte[] aad() { + return (VERSION + "\0" + domain.value).getBytes(StandardCharsets.UTF_8); + } + + private static byte[] deriveKey(byte[] masterKey, Domain domain) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(masterKey, "HmacSHA256")); + mac.update(DERIVATION_DOMAIN.getBytes(StandardCharsets.UTF_8)); + mac.update((byte) 0); + return mac.doFinal(domain.value.getBytes(StandardCharsets.UTF_8)); + } catch (GeneralSecurityException failure) { + throw new IllegalStateException("Transport encryption key derivation failed", failure); + } + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/security/TransportEnvelopeHttpCodec.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/security/TransportEnvelopeHttpCodec.java new file mode 100644 index 000000000..83cd7c82c --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/security/TransportEnvelopeHttpCodec.java @@ -0,0 +1,38 @@ +package com.bencodez.votingplugin.proxy.security; + +import java.util.Objects; +import java.util.function.Consumer; + +import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope; +import com.bencodez.simpleapi.servercomm.http.HttpEnvelopeWireCodec; +import com.bencodez.votingplugin.proxy.security.TransportEnvelopeEncryption.Decryption; + +/** Applies one immutable VotingPlugin encryption policy at the HTTP wire boundary. */ +public final class TransportEnvelopeHttpCodec implements HttpEnvelopeWireCodec { + private final TransportEnvelopeEncryption encryption; + private final Consumer rejectionLogger; + + public TransportEnvelopeHttpCodec(TransportEnvelopeEncryption encryption) { + this(encryption, ignored -> { }); + } + + public TransportEnvelopeHttpCodec(TransportEnvelopeEncryption encryption, Consumer rejectionLogger) { + this.encryption = Objects.requireNonNull(encryption, "encryption"); + this.rejectionLogger = Objects.requireNonNull(rejectionLogger, "rejectionLogger"); + } + + @Override + public JsonEnvelope encode(JsonEnvelope envelope) { + return encryption.encrypt(envelope); + } + + @Override + public JsonEnvelope decode(JsonEnvelope envelope) { + Decryption result = encryption.decrypt(envelope); + if (!result.accepted()) { + rejectionLogger.accept(result.reason()); + throw new IllegalArgumentException("HTTP envelope rejected by communication encryption policy"); + } + return result.envelope(); + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VelocityConfig.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VelocityConfig.java index efcb36751..db5e438cf 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VelocityConfig.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VelocityConfig.java @@ -560,6 +560,11 @@ public String getRedisPrefix() { return getString(getNode("Redis", "Prefix"), ""); } + @Override + public String getSharedTransportAuthentication() { + return getString(getNode("SharedTransportAuthentication"), "COMPATIBILITY"); + } + @Override public String getRedisUsername() { return getString(getNode("Redis", "Username"), ""); @@ -729,6 +734,11 @@ public boolean getPluginMessageEncryption() { return getBoolean(getNode("PluginMessageEncryption"), false); } + @Override + public boolean getCommunicationEncryption() { + return getBoolean(getNode("CommunicationEncryption"), false); + } + @Override public Collection getWaitUntilVoteDelaySites() { return getChildrenAsList(getNode("WaitUntilVoteDelay")); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VotingPluginVelocity.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VotingPluginVelocity.java index 1dcc4e8c0..14f3ebf1f 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VotingPluginVelocity.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VotingPluginVelocity.java @@ -347,6 +347,7 @@ public void onProxyInitialization(ProxyInitializeEvent event) { } config = new VelocityConfig(configFile); + ensureCommunicationSecret(); channel = buildChannelIdentifier(config.getPluginMessageChannel()); server.getChannelRegistrar().register(channel); @@ -386,6 +387,24 @@ public void onProxyInitialization(ProxyInitializeEvent event) { } } + private void ensureCommunicationSecret() { + try { + boolean created = com.bencodez.votingplugin.proxy.security.SharedSecretKeyFile + .ensure(dataDirectory.resolve("secretkey.key")); + if (created) logger.info("Created secretkey.key for VotingPlugin communication security"); + if (!config.getCommunicationEncryption()) logger.warn( + "CommunicationEncryption is disabled. Copy this proxy's secretkey.key to every VotingPlugin node, enable CommunicationEncryption everywhere, and restart (recommended)."); + } catch (IOException failure) { + boolean required = config.getCommunicationEncryption() + || com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator.Mode + .parse(config.getSharedTransportAuthentication()) + == com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator.Mode.REQUIRED; + if (required) throw new IllegalStateException( + "Unable to prepare required VotingPlugin communication secretkey.key", failure); + logger.warn("Unable to create optional secretkey.key; continuing with legacy plaintext/unsigned communication. Fix the data-folder permissions before enabling communication security."); + } + } + void initializeFirstRuntime() { // Full initialization creates the first runtime; there is no old runtime to retire. reloadAllInternal(true); diff --git a/VotingPlugin/src/main/resources/BungeeSettings.yml b/VotingPlugin/src/main/resources/BungeeSettings.yml index a8310126d..74131247d 100644 --- a/VotingPlugin/src/main/resources/BungeeSettings.yml +++ b/VotingPlugin/src/main/resources/BungeeSettings.yml @@ -103,6 +103,12 @@ MQTT: # Enables more debug messages for communication between servers # Use /votingpluginbungee status for testing communication BungeeDebug: false + +# Authenticates ordinary Redis and MQTT envelopes with the shared secretkey.key. +# COMPATIBILITY is the upgrade-safe default: it keeps outbound messages unsigned +# while older JARs remain. After every node has the same secretkey.key and an +# upgraded JAR, set REQUIRED everywhere and reload or restart. +SharedTransportAuthentication: COMPATIBILITY # If true, offline rewards will be stored per server, rather than global # This shouldn't need to be set unless you get alot of cross server rewards issues @@ -146,13 +152,12 @@ BungeeVotePartyGlobalCommands: [] # This setting requires a restart PluginMessageChannel: "vp:vp" -# Use this to encrypt the plugin messages -# Must be set on all servers the same -# Copy secretkey.key file to all servers (They all must match) -# This setting requires a restart -PluginMessageEncryption: false - -#################################################################################### +# Encrypt complete VotingPlugin proxy/backend messages for every communication method. +# Copy the proxy's secretkey.key to every backend first, then enable this everywhere. +# This setting requires a restart. Strongly recommended for shared or untrusted networks. +CommunicationEncryption: false + +#################################################################################### # Global mysql data handle for between server commmunications # This is NOT required for bungee voting to work # This is still a WIP, use with caution diff --git a/VotingPlugin/src/main/resources/bungeeconfig.yml b/VotingPlugin/src/main/resources/bungeeconfig.yml index 6effd4c26..ffe8c5b99 100644 --- a/VotingPlugin/src/main/resources/bungeeconfig.yml +++ b/VotingPlugin/src/main/resources/bungeeconfig.yml @@ -285,11 +285,16 @@ HTTP: # This setting requires a restart PluginMessageChannel: "vp:vp" -# Use this to encrypt the plugin messages -# Must be set on all servers the same -# Copy secretkey.key file to all servers (They all must match) -# This setting requires a restart -PluginMessageEncryption: false +# Encrypt complete VotingPlugin proxy/backend and multi-proxy messages for every +# communication method. Copy this proxy's secretkey.key to every node first, +# then enable this everywhere and restart. Strongly recommended. +CommunicationEncryption: false + +# Authenticates ordinary Redis, MQTT, and multi-proxy Redis envelopes with the +# shared secretkey.key. COMPATIBILITY is the upgrade-safe default: it keeps +# outbound messages unsigned while older JARs remain. After every node has the +# same key and an upgraded JAR, set REQUIRED everywhere and reload or restart. +SharedTransportAuthentication: COMPATIBILITY ########################################### # REDIS Settings diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/VotingPluginMainBackendProxyPublicationTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/VotingPluginMainBackendProxyPublicationTest.java index 294642596..bab55eea9 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/VotingPluginMainBackendProxyPublicationTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/VotingPluginMainBackendProxyPublicationTest.java @@ -35,6 +35,55 @@ import com.bencodez.votingplugin.proxy.BungeeMethod; class VotingPluginMainBackendProxyPublicationTest { + @Test + void ordinaryBackendSecurityReloadCallsActiveHandler() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS); + BackendProxyHandler active = mock(BackendProxyHandler.class); + BungeeSettings settings = mock(BungeeSettings.class); + setBackendProxyHandler(plugin, active); + setField(plugin, "bungeeSettings", settings); + when(settings.isUseBungeecoord()).thenReturn(true); + + plugin.reloadBackendProxyRuntime(true, false); + + verify(active).reloadSharedTransportSecurity(); + verify(active).reloadPresenceReporting(); + } + + @Test + void failedControlReplacementKeepsPreviousTransportSecurityPolicy() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS); + BackendProxyHandler previous = mock(BackendProxyHandler.class); + BackendProxyHandler replacement = mock(BackendProxyHandler.class); + BungeeSettings settings = mock(BungeeSettings.class); + setBackendProxyHandler(plugin, previous); + setField(plugin, "bungeeSettings", settings); + when(settings.isUseBungeecoord()).thenReturn(true); + doThrow(new IllegalStateException("handoff failed")).when(previous).completeHttpHandoff(replacement); + + // Full-editor Control preparation reloads settings before this replacement + // can be validated or published. It must not reconfigure the live handler. + plugin.reloadBackendProxyRuntime(false, false); + VotingPluginMain.BackendProxyRestart restart = restart(previous, replacement); + assertThrows(IllegalStateException.class, () -> plugin.completeBackendProxyHandlerRestart(restart)); + + assertSame(previous, plugin.getBackendProxyHandler()); + verify(previous, never()).reloadPresenceReporting(); + verify(previous, never()).reloadSharedTransportSecurity(); + } + + @Test + void controlPreparationDoesNotStartUnpublishedBackendRuntime() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS); + BungeeSettings settings = mock(BungeeSettings.class); + setField(plugin, "bungeeSettings", settings); + when(settings.isUseBungeecoord()).thenReturn(true); + + plugin.reloadBackendProxyRuntime(false, false); + + assertNull(plugin.getBackendProxyHandler()); + } + @Test void malformedReceiptStoreDisablesOnlyBackendProxyTransport(@TempDir Path directory) throws Exception { VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandlerLifecycleTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandlerLifecycleTest.java index 844db86ee..3bf6e9f0f 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandlerLifecycleTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandlerLifecycleTest.java @@ -29,6 +29,8 @@ import java.util.UUID; import java.nio.file.Path; import java.nio.file.Files; +import java.nio.charset.StandardCharsets; +import java.util.Base64; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CountDownLatch; import java.util.concurrent.LinkedBlockingQueue; @@ -65,6 +67,9 @@ import com.bencodez.votingplugin.backendproxy.presence.BackendPresenceManager; import com.bencodez.votingplugin.config.BungeeSettings; import com.bencodez.votingplugin.proxy.VotingPluginWire; +import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator; +import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator.Mode; +import com.bencodez.votingplugin.proxy.security.TransportEnvelopeEncryption; import com.bencodez.votingplugin.backendproxy.transport.MqttBackendProxyTransport; import com.bencodez.votingplugin.backendproxy.transport.MysqlBackendProxyTransport; import com.bencodez.votingplugin.backendproxy.transport.PluginMessagingBackendProxyTransport; @@ -76,6 +81,162 @@ import com.bencodez.votingplugin.proxy.BungeeMethod; class BackendProxyHandlerLifecycleTest { + @Test + void httpLeavesCommunicationEncryptionToItsWireCodec(@TempDir Path dataDirectory) throws Exception { + Path keyFile = dataDirectory.resolve("secretkey.key"); + Files.writeString(keyFile, Base64.getEncoder().encodeToString( + "0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.US_ASCII))); + com.bencodez.votingplugin.VotingPluginMain plugin = mock(com.bencodez.votingplugin.VotingPluginMain.class); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + doAnswer(invocation -> { + ((Runnable) invocation.getArgument(1)).run(); + return null; + }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + BackendProxyHandler handler = new BackendProxyHandler(plugin); + TransportEnvelopeEncryption encryption = TransportEnvelopeEncryption.load( + keyFile, TransportEnvelopeEncryption.Domain.PROXY_BACKEND, true); + setField(handler, "method", BungeeMethod.HTTP); + setField(handler, "communicationEncryption", encryption); + BackendProxyTransportManager manager = (BackendProxyTransportManager) getField(handler, "transportManager"); + BackendProxyTransport transport = mock(BackendProxyTransport.class); + setField(manager, "transport", transport); + Class type = Class.forName(BackendProxyHandler.class.getName() + "$EncryptedGlobalMessageHandler"); + var constructor = type.getDeclaredConstructor(BackendProxyHandler.class); + constructor.setAccessible(true); + GlobalMessageHandler messages = (GlobalMessageHandler) constructor.newInstance(handler); + JsonEnvelope semantic = JsonEnvelope.builder("test").put("value", "payload").build(); + + messages.sendMessage(semantic); + + org.mockito.ArgumentCaptor outbound = org.mockito.ArgumentCaptor.forClass(JsonEnvelope.class); + verify(transport).send(outbound.capture()); + assertEquals(semantic.getSubChannel(), outbound.getValue().getSubChannel()); + assertEquals(semantic.getFields(), outbound.getValue().getFields()); + AtomicReference received = new AtomicReference<>(); + messages.addListener(new com.bencodez.simpleapi.servercomm.global.GlobalMessageListener("test") { + @Override public void onReceive(JsonEnvelope envelope) { received.set(envelope); } + }); + handler.activateInboundMessages(); + messages.onMessage(semantic); + assertNotNull(received.get()); + assertEquals(semantic.getSubChannel(), received.get().getSubChannel()); + assertEquals(semantic.getFields(), received.get().getFields()); + } + + @Test + void redisAndMqttSecurityReloadAppliesChangedPolicyWithoutResettingUnchangedReplayState( + @TempDir Path dataDirectory) throws Exception { + Path keyFile = dataDirectory.resolve("secretkey.key"); + Files.writeString(keyFile, Base64.getEncoder().encodeToString( + "0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.US_ASCII))); + for (BungeeMethod method : new BungeeMethod[] { BungeeMethod.REDIS, BungeeMethod.MQTT }) { + com.bencodez.votingplugin.VotingPluginMain plugin = mock(com.bencodez.votingplugin.VotingPluginMain.class); + BungeeSettings settings = mock(BungeeSettings.class); + when(plugin.getDataFolder()).thenReturn(dataDirectory.toFile()); + when(plugin.getBungeeSettings()).thenReturn(settings); + when(settings.getSharedTransportAuthentication()).thenReturn("REQUIRED"); + when(settings.isCommunicationEncryption()).thenReturn(true); + BackendProxyHandler handler = new BackendProxyHandler(plugin); + setField(handler, "method", method); + setField(handler, "sharedTransportMode", Mode.COMPATIBILITY); + setField(handler, "communicationEncryptionEnabled", false); + setField(handler, "communicationEncryption", TransportEnvelopeEncryption.load(keyFile, + TransportEnvelopeEncryption.Domain.PROXY_BACKEND, false)); + BackendProxyTransportManager manager = (BackendProxyTransportManager) getField(handler, "transportManager"); + BackendProxyTransport transport = method == BungeeMethod.REDIS + ? new RedisBackendProxyTransport(plugin, new ProcessedVoteCache()) + : new MqttBackendProxyTransport(plugin); + setField(transport, "authenticator", SharedTransportEnvelopeAuthenticator.load(keyFile, + Mode.COMPATIBILITY)); + setField(manager, "transport", transport); + + handler.reloadSharedTransportSecurity(); + + SharedTransportEnvelopeAuthenticator replacement = + (SharedTransportEnvelopeAuthenticator) getField(transport, "authenticator"); + assertEquals(Mode.REQUIRED, replacement.mode()); + assertTrue(((TransportEnvelopeEncryption) getField(handler, "communicationEncryption")).enabled()); + handler.reloadSharedTransportSecurity(); + assertSame(replacement, getField(transport, "authenticator")); + + when(settings.getSharedTransportAuthentication()).thenReturn("COMPATIBILITY"); + when(settings.isCommunicationEncryption()).thenReturn(false); + handler.reloadSharedTransportSecurity(); + assertEquals(Mode.COMPATIBILITY, + ((SharedTransportEnvelopeAuthenticator) getField(transport, "authenticator")).mode()); + assertFalse(((TransportEnvelopeEncryption) getField(handler, "communicationEncryption")).enabled()); + } + } + + @Test + void failedSharedTransportSecurityReloadRetainsActivePolicy(@TempDir Path dataDirectory) throws Exception { + com.bencodez.votingplugin.VotingPluginMain plugin = mock(com.bencodez.votingplugin.VotingPluginMain.class); + BungeeSettings settings = mock(BungeeSettings.class); + when(plugin.getDataFolder()).thenReturn(dataDirectory.toFile()); + when(plugin.getBungeeSettings()).thenReturn(settings); + when(settings.getSharedTransportAuthentication()).thenReturn("REQUIRED"); + when(settings.isCommunicationEncryption()).thenReturn(true); + BackendProxyHandler handler = new BackendProxyHandler(plugin); + setField(handler, "method", BungeeMethod.REDIS); + setField(handler, "sharedTransportMode", Mode.COMPATIBILITY); + setField(handler, "communicationEncryptionEnabled", false); + RedisBackendProxyTransport transport = new RedisBackendProxyTransport(plugin, new ProcessedVoteCache()); + SharedTransportEnvelopeAuthenticator original = SharedTransportEnvelopeAuthenticator.load( + dataDirectory.resolve("secretkey.key"), Mode.COMPATIBILITY); + setField(transport, "authenticator", original); + BackendProxyTransportManager manager = (BackendProxyTransportManager) getField(handler, "transportManager"); + setField(manager, "transport", transport); + + assertThrows(IllegalStateException.class, handler::reloadSharedTransportSecurity); + assertSame(original, getField(transport, "authenticator")); + assertEquals(Mode.COMPATIBILITY, getField(handler, "sharedTransportMode")); + assertEquals(false, getField(handler, "communicationEncryptionEnabled")); + } + + @Test + void sharedTransportReloadReadsRotatedKeyWhenFlagsStayEnabled(@TempDir Path dataDirectory) throws Exception { + Path keyFile = dataDirectory.resolve("secretkey.key"); + for (BungeeMethod method : new BungeeMethod[] { BungeeMethod.REDIS, BungeeMethod.MQTT }) { + Files.writeString(keyFile, Base64.getEncoder().encodeToString( + "0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.US_ASCII))); + com.bencodez.votingplugin.VotingPluginMain plugin = mock(com.bencodez.votingplugin.VotingPluginMain.class); + BungeeSettings settings = mock(BungeeSettings.class); + when(plugin.getDataFolder()).thenReturn(dataDirectory.toFile()); + when(plugin.getBungeeSettings()).thenReturn(settings); + when(settings.getSharedTransportAuthentication()).thenReturn("REQUIRED"); + when(settings.isCommunicationEncryption()).thenReturn(true); + BackendProxyHandler handler = new BackendProxyHandler(plugin); + setField(handler, "method", method); + setField(handler, "sharedTransportMode", Mode.REQUIRED); + setField(handler, "communicationEncryptionEnabled", true); + TransportEnvelopeEncryption originalEncryption = TransportEnvelopeEncryption.load( + keyFile, TransportEnvelopeEncryption.Domain.PROXY_BACKEND, true); + setField(handler, "communicationEncryption", originalEncryption); + BackendProxyTransportManager manager = (BackendProxyTransportManager) getField(handler, "transportManager"); + BackendProxyTransport transport = method == BungeeMethod.REDIS + ? new RedisBackendProxyTransport(plugin, new ProcessedVoteCache()) + : new MqttBackendProxyTransport(plugin); + SharedTransportEnvelopeAuthenticator originalAuthenticator = + SharedTransportEnvelopeAuthenticator.load(keyFile, Mode.REQUIRED); + setField(transport, "authenticator", originalAuthenticator); + setField(manager, "transport", transport); + handler.reloadSharedTransportSecurity(); + originalAuthenticator = (SharedTransportEnvelopeAuthenticator) getField(transport, "authenticator"); + originalEncryption = (TransportEnvelopeEncryption) getField(handler, "communicationEncryption"); + + Files.writeString(keyFile, Base64.getEncoder().encodeToString( + "abcdef0123456789abcdef0123456789".getBytes(StandardCharsets.US_ASCII))); + handler.reloadSharedTransportSecurity(); + + SharedTransportEnvelopeAuthenticator replacement = + (SharedTransportEnvelopeAuthenticator) getField(transport, "authenticator"); + assertFalse(originalAuthenticator.hasEquivalentInboundPolicy(replacement)); + assertFalse(originalEncryption.hasEquivalentInboundPolicy( + (TransportEnvelopeEncryption) getField(handler, "communicationEncryption"))); + } + } + @Test void globalDataWakeupMovesOffTheCallingAndPrimaryThreads() { com.bencodez.votingplugin.VotingPluginMain plugin = mock(com.bencodez.votingplugin.VotingPluginMain.class); @@ -1282,6 +1443,48 @@ void stagedInboundRoutesThroughRestoredPredecessorOnRollback() throws Exception verify(replacementDispatch, never()).run(); } + @Test + void stagedInboundIsNotForwardedAcrossDifferentSharedAuthenticationPolicies(@TempDir Path dataDirectory) + throws Exception { + Path keyFile = dataDirectory.resolve("secretkey.key"); + Files.writeString(keyFile, Base64.getEncoder().encodeToString( + "0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.US_ASCII))); + BackendProxyHandler previous = new BackendProxyHandler(null); + BackendProxyHandler replacement = new BackendProxyHandler(null); + setField(previous, "method", BungeeMethod.REDIS); + setField(replacement, "method", BungeeMethod.REDIS); + GlobalMessageHandler previousMessages = mock(GlobalMessageHandler.class); + setField(previous, "globalMessageHandler", previousMessages); + installRedisAuthenticator(previous, SharedTransportEnvelopeAuthenticator.load(keyFile, Mode.REQUIRED)); + installRedisAuthenticator(replacement, + SharedTransportEnvelopeAuthenticator.load(keyFile, Mode.COMPATIBILITY)); + JsonEnvelope envelope = JsonEnvelope.builder("rollback").build(); + Runnable replacementDispatch = mock(Runnable.class); + CountDownLatch started = new CountDownLatch(1); + + CompletableFuture callback = CompletableFuture.runAsync(() -> { + started.countDown(); + replacement.dispatchIncomingAfterPublication(envelope, replacementDispatch); + }); + assertTrue(started.await(1, TimeUnit.SECONDS)); + assertFalse(callback.isDone()); + + replacement.abortStagedInboundTo(previous); + callback.get(1, TimeUnit.SECONDS); + verifyNoInteractions(previousMessages); + verify(replacementDispatch, never()).run(); + } + + private void installRedisAuthenticator(BackendProxyHandler handler, + SharedTransportEnvelopeAuthenticator authenticator) throws Exception { + Field managerField = BackendProxyHandler.class.getDeclaredField("transportManager"); + managerField.setAccessible(true); + BackendProxyTransportManager manager = (BackendProxyTransportManager) managerField.get(handler); + RedisBackendProxyTransport transport = new RedisBackendProxyTransport(null); + setField(transport, "authenticator", authenticator); + setField(manager, "transport", transport); + } + @Test void failedPluginMessagePublicationRestoresPreviousSharedState() { com.bencodez.votingplugin.VotingPluginMain plugin = mock(com.bencodez.votingplugin.VotingPluginMain.class); @@ -1853,7 +2056,7 @@ void preparesHttpDeliveryQueueWhenSwitchingToAnotherMethod() throws Exception { } @Test - void redisSendKeepsTheChannelCapturedByTheActiveTransport() throws Exception { + void redisSendKeepsTheChannelCapturedByTheActiveTransport(@TempDir Path dataDirectory) throws Exception { com.bencodez.votingplugin.VotingPluginMain plugin = mock(com.bencodez.votingplugin.VotingPluginMain.class); BungeeSettings settings = mock(BungeeSettings.class); @@ -1863,6 +2066,8 @@ void redisSendKeepsTheChannelCapturedByTheActiveTransport() throws Exception { RedisHandler redis = mock(RedisHandler.class); setField(transport, "redisHandler", redis); setField(transport, "publishChannel", "old:VotingPlugin"); + setField(transport, "authenticator", authenticator(dataDirectory)); + when(settings.getServer()).thenReturn("backend-a"); transport.send(com.bencodez.simpleapi.servercomm.codec.JsonEnvelope.builder("vote").build()); @@ -1870,7 +2075,7 @@ void redisSendKeepsTheChannelCapturedByTheActiveTransport() throws Exception { } @Test - void mqttSendKeepsTheTopicCapturedByTheActiveTransport() throws Exception { + void mqttSendKeepsTheTopicCapturedByTheActiveTransport(@TempDir Path dataDirectory) throws Exception { com.bencodez.votingplugin.VotingPluginMain plugin = mock(com.bencodez.votingplugin.VotingPluginMain.class); BungeeSettings settings = mock(BungeeSettings.class); @@ -1880,6 +2085,8 @@ void mqttSendKeepsTheTopicCapturedByTheActiveTransport() throws Exception { MqttHandler mqtt = mock(MqttHandler.class); setField(transport, "mqttHandler", mqtt); setField(transport, "publishTopic", "old/votingplugin/servers/proxy"); + setField(transport, "authenticator", authenticator(dataDirectory)); + when(settings.getServer()).thenReturn("backend-a"); assertTrue(transport.send(com.bencodez.simpleapi.servercomm.codec.JsonEnvelope.builder("vote").build())); @@ -1887,12 +2094,16 @@ void mqttSendKeepsTheTopicCapturedByTheActiveTransport() throws Exception { } @Test - void mqttAndMysqlReportRejectedHandoffDeliveries() throws Exception { + void mqttAndMysqlReportRejectedHandoffDeliveries(@TempDir Path dataDirectory) throws Exception { com.bencodez.votingplugin.VotingPluginMain plugin = mock(com.bencodez.votingplugin.VotingPluginMain.class); MqttBackendProxyTransport mqttTransport = new MqttBackendProxyTransport(plugin); MqttHandler mqtt = mock(MqttHandler.class); setField(mqttTransport, "mqttHandler", mqtt); setField(mqttTransport, "publishTopic", "votingplugin/servers/proxy"); + setField(mqttTransport, "authenticator", authenticator(dataDirectory)); + BungeeSettings mqttSettings = mock(BungeeSettings.class); + when(plugin.getBungeeSettings()).thenReturn(mqttSettings); + when(mqttSettings.getServer()).thenReturn("backend-a"); doThrow(new IllegalStateException("publish failed")).when(mqtt).publishEnvelope(any(), any()); JsonEnvelope mqttEnvelope = JsonEnvelope.builder("mqtt").build(); @@ -2353,6 +2564,13 @@ private void setField(Object target, String name, Object value) throws Exception throw new NoSuchFieldException(name); } + private static SharedTransportEnvelopeAuthenticator authenticator(Path dataDirectory) throws Exception { + Path keyFile = dataDirectory.resolve("secretkey.key"); + Files.writeString(keyFile, Base64.getEncoder().encodeToString( + "0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.US_ASCII))); + return SharedTransportEnvelopeAuthenticator.load(keyFile, Mode.REQUIRED); + } + private Object getField(Object target, String name) throws Exception { Class type = target.getClass(); while (type != null) { diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/transport/HttpBackendProxyTransportTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/transport/HttpBackendProxyTransportTest.java index 35138136b..5dc2e0982 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/transport/HttpBackendProxyTransportTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/transport/HttpBackendProxyTransportTest.java @@ -722,6 +722,7 @@ void rollbackRecreateKeepsAnOversizedCombinedQueueInTheDrainedHandoffLane() thro setField(transport, "configuredConnectionCode", ""); setField(transport, "configuredMessageHandler", mock(GlobalMessageHandler.class)); setField(transport, "restoreUnenrolledState", true); + setField(transport, "wireCodec", com.bencodez.simpleapi.servercomm.http.HttpEnvelopeWireCodec.identity()); List queued = new ArrayList<>(); JsonEnvelope startup = JsonEnvelope.builder("startup").build(); queued.add(startup); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/transport/MqttBackendProxyTransportTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/transport/MqttBackendProxyTransportTest.java new file mode 100644 index 000000000..acc819b64 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/transport/MqttBackendProxyTransportTest.java @@ -0,0 +1,97 @@ +package com.bencodez.votingplugin.backendproxy.transport; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Base64; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope; +import com.bencodez.simpleapi.servercomm.global.GlobalMessageHandler; +import com.bencodez.votingplugin.proxy.VotingPluginWire; +import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator; +import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator.Domain; +import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator.Mode; +import com.bencodez.votingplugin.proxy.security.TransportEnvelopeEncryption; + +class MqttBackendProxyTransportTest { + @TempDir + Path temporaryDirectory; + + @Test + void onePolicySnapshotAuthenticatesAndDecryptsTheSameMqttEnvelope() throws Exception { + SharedTransportEnvelopeAuthenticator authenticator = authenticator(); + TransportEnvelopeEncryption encryption = TransportEnvelopeEncryption.load( + temporaryDirectory.resolve("secretkey.key"), TransportEnvelopeEncryption.Domain.PROXY_BACKEND, true); + MqttBackendProxyTransport transport = new MqttBackendProxyTransport(null); + GlobalMessageHandler messages = mock(GlobalMessageHandler.class); + setField(transport, "messageHandler", messages); + setField(transport, "authenticator", authenticator); + transport.updateSecurity(authenticator, encryption); + JsonEnvelope vote = JsonEnvelope.builder(VotingPluginWire.SUB_VOTE).put("player", "Alex").build(); + JsonEnvelope signed = authenticator.sign(encryption.encrypt(vote), Domain.MQTT_PROXY_BACKEND, "proxy-a", + "votingplugin/servers/backend-a"); + + transport.acceptAuthenticatedEnvelope(signed, "votingplugin/servers/backend-a"); + + verify(messages).onMessage(org.mockito.ArgumentMatchers.argThat(received -> + VotingPluginWire.SUB_VOTE.equals(received.getSubChannel()) + && "Alex".equals(received.getFields().get("player")))); + } + + @Test + void authenticatedVoteIsAcceptedAndUnsignedVoteIsRejected() throws Exception { + SharedTransportEnvelopeAuthenticator authenticator = authenticator(); + MqttBackendProxyTransport transport = new MqttBackendProxyTransport(null); + GlobalMessageHandler messages = mock(GlobalMessageHandler.class); + setField(transport, "messageHandler", messages); + setField(transport, "authenticator", authenticator); + JsonEnvelope vote = JsonEnvelope.builder(VotingPluginWire.SUB_VOTE).put("player", "Alex").build(); + + transport.acceptAuthenticatedEnvelope(authenticator.sign(vote, Domain.MQTT_PROXY_BACKEND, "proxy-a", "votingplugin/servers/backend-a"), + "votingplugin/servers/backend-a"); + transport.acceptAuthenticatedEnvelope(vote, "votingplugin/servers/backend-a"); + + verify(messages).onMessage(org.mockito.ArgumentMatchers.argThat(received -> + VotingPluginWire.SUB_VOTE.equals(received.getSubChannel()) + && vote.getFields().equals(received.getFields()))); + verifyNoMoreInteractions(messages); + } + + @Test + void copiedMqttVoteCannotAuthenticateOnAnotherBackendTopic() throws Exception { + SharedTransportEnvelopeAuthenticator authenticator = authenticator(); + MqttBackendProxyTransport transport = new MqttBackendProxyTransport(null); + GlobalMessageHandler messages = mock(GlobalMessageHandler.class); + setField(transport, "messageHandler", messages); + setField(transport, "authenticator", authenticator); + JsonEnvelope vote = JsonEnvelope.builder(VotingPluginWire.SUB_VOTE).build(); + JsonEnvelope signed = authenticator.sign(vote, Domain.MQTT_PROXY_BACKEND, "proxy-a", + "votingplugin/servers/backend-a"); + + transport.acceptAuthenticatedEnvelope(signed, "votingplugin/servers/backend-b"); + org.mockito.Mockito.verifyNoInteractions(messages); + transport.acceptAuthenticatedEnvelope(signed, "votingplugin/servers/backend-a"); + verify(messages).onMessage(org.mockito.ArgumentMatchers.any()); + } + + private SharedTransportEnvelopeAuthenticator authenticator() throws Exception { + Path keyFile = temporaryDirectory.resolve("secretkey.key"); + Files.writeString(keyFile, Base64.getEncoder().encodeToString( + "0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.US_ASCII))); + return SharedTransportEnvelopeAuthenticator.load(keyFile, Mode.REQUIRED); + } + + private static void setField(Object target, String name, Object value) throws Exception { + Field field = target.getClass().getDeclaredField(name); + field.setAccessible(true); + field.set(target, value); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/transport/RedisBackendProxyTransportTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/transport/RedisBackendProxyTransportTest.java index ff2590734..6d16cc901 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/transport/RedisBackendProxyTransportTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/transport/RedisBackendProxyTransportTest.java @@ -11,15 +11,21 @@ import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; import static org.mockito.Mockito.spy; import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Base64; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.CopyOnWriteArrayList; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import com.bencodez.simpleapi.scheduler.BukkitScheduler; import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope; @@ -28,10 +34,95 @@ import com.bencodez.votingplugin.backendproxy.cache.ProcessedVoteCache; import com.bencodez.votingplugin.proxy.BungeeMethod; import com.bencodez.votingplugin.proxy.VotingPluginWire; +import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator; +import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator.Domain; +import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator.Mode; +import com.bencodez.votingplugin.proxy.security.TransportEnvelopeEncryption; import redis.clients.jedis.DefaultJedisClientConfig; class RedisBackendProxyTransportTest { + @TempDir + Path temporaryDirectory; + + @Test + void onePolicySnapshotAuthenticatesAndDecryptsTheSameRedisEnvelope() throws Exception { + SharedTransportEnvelopeAuthenticator authenticator = authenticator(); + TransportEnvelopeEncryption encryption = TransportEnvelopeEncryption.load( + temporaryDirectory.resolve("secretkey.key"), TransportEnvelopeEncryption.Domain.PROXY_BACKEND, true); + RedisBackendProxyTransport transport = new RedisBackendProxyTransport(null, new ProcessedVoteCache()); + GlobalMessageHandler messages = mock(GlobalMessageHandler.class); + setField(transport, "messageHandler", messages); + setField(transport, "authenticator", authenticator); + transport.updateSecurity(authenticator, encryption); + JsonEnvelope vote = JsonEnvelope.builder(VotingPluginWire.SUB_VOTE).put("player", "Alex").build(); + JsonEnvelope encrypted = VotingPluginWire.withRedisDeliveryId(encryption.encrypt(vote)); + JsonEnvelope signed = authenticator.sign(encrypted, Domain.REDIS_PROXY_BACKEND, "proxy-a", + "VotingPlugin_backend-a"); + + transport.acceptAuthenticatedEnvelope(signed, "VotingPlugin_backend-a"); + + verify(messages).onMessage(org.mockito.ArgumentMatchers.argThat(received -> + VotingPluginWire.SUB_VOTE.equals(received.getSubChannel()) + && "Alex".equals(received.getFields().get("player")))); + } + + @Test + void authenticatedVoteIsAcceptedAndUnsignedPresenceIsRejectedBeforeDispatch() throws Exception { + SharedTransportEnvelopeAuthenticator authenticator = authenticator(); + RedisBackendProxyTransport transport = new RedisBackendProxyTransport(null, new ProcessedVoteCache()); + GlobalMessageHandler messages = mock(GlobalMessageHandler.class); + setField(transport, "messageHandler", messages); + setField(transport, "authenticator", authenticator); + JsonEnvelope vote = VotingPluginWire.vote("Alex", "00000000-0000-0000-0000-000000000001", "Site", 1L, + true, true, "", java.util.UUID.randomUUID(), true, false, 1, 1); + JsonEnvelope signedVote = authenticator.sign(VotingPluginWire.withRedisDeliveryId(vote), + Domain.REDIS_PROXY_BACKEND, "proxy-a", "VotingPlugin_backend-a"); + + transport.acceptAuthenticatedEnvelope(signedVote, "VotingPlugin_backend-a"); + transport.acceptAuthenticatedEnvelope(vote, "VotingPlugin_backend-a"); + transport.acceptAuthenticatedEnvelope(VotingPluginWire.login("Alex", + "00000000-0000-0000-0000-000000000001", "backend-a"), "VotingPlugin_backend-a"); + + verify(messages).onMessage(org.mockito.ArgumentMatchers.argThat(received -> + VotingPluginWire.SUB_VOTE.equals(received.getSubChannel()) + && "Alex".equals(received.getFields().get(VotingPluginWire.K_PLAYER)) + && !received.getFields().containsKey(SharedTransportEnvelopeAuthenticator.K_MAC))); + verifyNoMoreInteractions(messages); + } + + @Test + void copiedRedisVoteCannotAuthenticateOnAnotherBackendChannel() throws Exception { + SharedTransportEnvelopeAuthenticator authenticator = authenticator(); + RedisBackendProxyTransport transport = new RedisBackendProxyTransport(null, new ProcessedVoteCache()); + GlobalMessageHandler messages = mock(GlobalMessageHandler.class); + setField(transport, "messageHandler", messages); + setField(transport, "authenticator", authenticator); + JsonEnvelope vote = VotingPluginWire.withRedisDeliveryId(JsonEnvelope.builder(VotingPluginWire.SUB_VOTE).build()); + JsonEnvelope signed = authenticator.sign(vote, Domain.REDIS_PROXY_BACKEND, "proxy-a", + "VotingPlugin_backend-a"); + + transport.acceptAuthenticatedEnvelope(signed, "VotingPlugin_backend-b"); + verifyNoInteractions(messages); + transport.acceptAuthenticatedEnvelope(signed, "VotingPlugin_backend-a"); + verify(messages).onMessage(org.mockito.ArgumentMatchers.any()); + } + + @Test + void exactAuthenticatedRedisReplayCannotDuplicateVoteProcessing() throws Exception { + SharedTransportEnvelopeAuthenticator authenticator = authenticator(); + RedisBackendProxyTransport transport = new RedisBackendProxyTransport(null, new ProcessedVoteCache()); + GlobalMessageHandler messages = mock(GlobalMessageHandler.class); + setField(transport, "messageHandler", messages); + setField(transport, "authenticator", authenticator); + JsonEnvelope signed = authenticator.sign(VotingPluginWire.withRedisDeliveryId( + JsonEnvelope.builder(VotingPluginWire.SUB_VOTE).build()), Domain.REDIS_PROXY_BACKEND, "proxy-a", "VotingPlugin_backend-a"); + + transport.acceptAuthenticatedEnvelope(signed, "VotingPlugin_backend-a"); + transport.acceptAuthenticatedEnvelope(signed, "VotingPlugin_backend-a"); + + verify(messages, times(1)).onMessage(org.mockito.ArgumentMatchers.any()); + } @Test void validationHonorsTlsAndHostnameVerification() { @@ -43,6 +134,13 @@ void validationHonorsTlsAndHostnameVerification() { assertEquals("HTTPS", config.getSslParameters().getEndpointIdentificationAlgorithm()); } + private SharedTransportEnvelopeAuthenticator authenticator() throws Exception { + Path keyFile = temporaryDirectory.resolve("secretkey.key"); + Files.writeString(keyFile, Base64.getEncoder().encodeToString( + "0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.US_ASCII))); + return SharedTransportEnvelopeAuthenticator.load(keyFile, Mode.REQUIRED); + } + @Test void validationKeepsTlsDisabledByDefault() { assertFalse(RedisBackendProxyTransport.buildValidationClientConfig(0, null, null, false).isSsl()); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/transport/SharedInboundPolicyTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/transport/SharedInboundPolicyTest.java new file mode 100644 index 000000000..92a580de4 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/transport/SharedInboundPolicyTest.java @@ -0,0 +1,43 @@ +package com.bencodez.votingplugin.backendproxy.transport; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Base64; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator; +import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator.Mode; +import com.bencodez.votingplugin.proxy.security.TransportEnvelopeEncryption; + +class SharedInboundPolicyTest { + @Test + void equivalenceIncludesTransportDestinationAndAuthenticationPolicy(@TempDir Path directory) throws Exception { + Path keyFile = directory.resolve("secretkey.key"); + Files.writeString(keyFile, Base64.getEncoder().encodeToString( + "0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.US_ASCII))); + SharedTransportEnvelopeAuthenticator required = SharedTransportEnvelopeAuthenticator.load(keyFile, Mode.REQUIRED); + TransportEnvelopeEncryption encrypted = TransportEnvelopeEncryption.load(keyFile, + TransportEnvelopeEncryption.Domain.PROXY_BACKEND, true); + SharedInboundPolicy policy = new SharedInboundPolicy(RedisBackendProxyTransport.class, "vp:a", required, + encrypted); + + assertTrue(policy.hasEquivalentPolicy(new SharedInboundPolicy(RedisBackendProxyTransport.class, "vp:a", + SharedTransportEnvelopeAuthenticator.load(keyFile, Mode.REQUIRED), TransportEnvelopeEncryption.load(keyFile, + TransportEnvelopeEncryption.Domain.PROXY_BACKEND, true)))); + assertFalse(policy.hasEquivalentPolicy(new SharedInboundPolicy(RedisBackendProxyTransport.class, "vp:b", + SharedTransportEnvelopeAuthenticator.load(keyFile, Mode.REQUIRED), encrypted))); + assertFalse(policy.hasEquivalentPolicy(new SharedInboundPolicy(MqttBackendProxyTransport.class, "vp:a", + SharedTransportEnvelopeAuthenticator.load(keyFile, Mode.REQUIRED), encrypted))); + assertFalse(policy.hasEquivalentPolicy(new SharedInboundPolicy(RedisBackendProxyTransport.class, "vp:a", + SharedTransportEnvelopeAuthenticator.load(keyFile, Mode.COMPATIBILITY), encrypted))); + assertFalse(policy.hasEquivalentPolicy(new SharedInboundPolicy(RedisBackendProxyTransport.class, "vp:a", + SharedTransportEnvelopeAuthenticator.load(keyFile, Mode.REQUIRED), TransportEnvelopeEncryption.load(keyFile, + TransportEnvelopeEncryption.Domain.PROXY_BACKEND, false)))); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/ProxyRuntimeReplacementLifecycleTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/ProxyRuntimeReplacementLifecycleTest.java index 9cda328d1..503806a93 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/ProxyRuntimeReplacementLifecycleTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/ProxyRuntimeReplacementLifecycleTest.java @@ -2,9 +2,18 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.nio.file.Path; + +import org.junit.jupiter.api.io.TempDir; + +import com.bencodez.votingplugin.tests.VotingPluginProxyTestImpl; import org.junit.jupiter.api.Test; @@ -36,5 +45,22 @@ void fullReloadCleansLoadedRuntimeExactlyOnce() { verify(previous).prepareForRuntimeReplacement(); verify(previous).completeRuntimeReplacementShutdown(); + verify(previous).validateReplacementTransportSecurity(); + } + + @Test + void invalidRequiredAuthenticationLeavesExistingRuntimeActive(@TempDir Path dataDirectory) { + VotingPluginProxyTestImpl previous = org.mockito.Mockito.spy(new VotingPluginProxyTestImpl()); + previous.setDataFolder(dataDirectory.toFile()); + when(previous.getConfig().getBungeeMethod()).thenReturn("REDIS"); + when(previous.getConfig().getSharedTransportAuthentication()).thenReturn("REQUIRED"); + + assertThrows(IllegalStateException.class, () -> ProxyRuntimeReplacementLifecycle.prepare(previous)); + verify(previous, never()).prepareForRuntimeReplacement(); + verify(previous, never()).completeRuntimeReplacementShutdown(); + + when(previous.getConfig().getSharedTransportAuthentication()).thenReturn("INVALID"); + assertThrows(IllegalArgumentException.class, () -> ProxyRuntimeReplacementLifecycle.prepare(previous)); + verify(previous, never()).prepareForRuntimeReplacement(); } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/VotingPluginProxyLifecycleTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/VotingPluginProxyLifecycleTest.java index 2d2cf68ce..7affc8ab8 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/VotingPluginProxyLifecycleTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/VotingPluginProxyLifecycleTest.java @@ -1,18 +1,24 @@ package com.bencodez.votingplugin.proxy; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; import java.lang.reflect.Field; import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Base64; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.Map; @@ -22,6 +28,7 @@ import java.util.concurrent.TimeUnit; import java.util.UUID; import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -32,9 +39,182 @@ import com.bencodez.simpleapi.servercomm.sockets.ClientHandler; import com.bencodez.votingplugin.proxy.control.ControlConnector; import com.bencodez.votingplugin.proxy.control.HostedControlManager; +import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator; +import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator.Domain; +import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator.Mode; +import com.bencodez.votingplugin.proxy.security.TransportEnvelopeEncryption; import com.bencodez.votingplugin.tests.VotingPluginProxyTestImpl; class VotingPluginProxyLifecycleTest { + @Test + void standaloneSocketPathUsesCommunicationEnvelopeEncryption(@TempDir Path dataDirectory) throws Exception { + VotingPluginProxyTestImpl proxy = new VotingPluginProxyTestImpl(); + proxy.setMethod(BungeeMethod.SOCKETS); + Path keyFile = dataDirectory.resolve("secretkey.key"); + Files.writeString(keyFile, Base64.getEncoder().encodeToString( + "0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.US_ASCII))); + TransportEnvelopeEncryption encryption = TransportEnvelopeEncryption.load(keyFile, + TransportEnvelopeEncryption.Domain.PROXY_BACKEND, true); + Field encryptionField = VotingPluginProxy.class.getDeclaredField("communicationEncryption"); + encryptionField.setAccessible(true); + encryptionField.set(proxy, encryption); + ClientHandler client = mock(ClientHandler.class); + Field handles = VotingPluginProxy.class.getDeclaredField("clientHandles"); + handles.setAccessible(true); + handles.set(proxy, new HashMap<>(Map.of("lobby", client))); + JsonEnvelope original = JsonEnvelope.builder("Vote").put("player", "Alex").build(); + + assertTrue(proxy.sendProxyBroadcastEnvelopeNow("lobby", original)); + + ArgumentCaptor sent = ArgumentCaptor.forClass(JsonEnvelope.class); + verify(client).sendEnvelope(sent.capture()); + TransportEnvelopeEncryption.Decryption decrypted = encryption.decrypt(sent.getValue()); + assertTrue(decrypted.accepted()); + assertEquals(original.getSubChannel(), decrypted.envelope().getSubChannel()); + assertEquals(original.getFields(), decrypted.envelope().getFields()); + } + + @Test + void unsignedRedisPresenceCannotReachProxyPresenceHandling(@TempDir Path dataDirectory) throws Exception { + VotingPluginProxyTestImpl proxy = new VotingPluginProxyTestImpl(); + Path keyFile = dataDirectory.resolve("secretkey.key"); + Files.writeString(keyFile, Base64.getEncoder().encodeToString( + "0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.US_ASCII))); + Field authentication = VotingPluginProxy.class.getDeclaredField("sharedTransportAuthenticator"); + authentication.setAccessible(true); + authentication.set(proxy, SharedTransportEnvelopeAuthenticator.load(keyFile, Mode.REQUIRED)); + @SuppressWarnings("unchecked") + Consumer accepted = mock(Consumer.class); + + ((VotingPluginProxy) proxy).acceptSharedTransportEnvelope(VotingPluginWire.login("Alex", + "00000000-0000-0000-0000-000000000001", "backend-a"), Domain.REDIS_PROXY_BACKEND, + "VotingPlugin", accepted); + + verifyNoInteractions(accepted); + } + + @Test + void acceptedSharedTransportCallbackRunsOutsideSecurityLock(@TempDir Path dataDirectory) throws Exception { + VotingPluginProxyTestImpl proxy = new VotingPluginProxyTestImpl(); + Path keyFile = dataDirectory.resolve("secretkey.key"); + Files.writeString(keyFile, Base64.getEncoder().encodeToString( + "0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.US_ASCII))); + SharedTransportEnvelopeAuthenticator authenticator = SharedTransportEnvelopeAuthenticator.load(keyFile, + Mode.REQUIRED); + Field authentication = VotingPluginProxy.class.getDeclaredField("sharedTransportAuthenticator"); + authentication.setAccessible(true); + authentication.set(proxy, authenticator); + Field securityLockField = VotingPluginProxy.class.getDeclaredField("transportSecurityLock"); + securityLockField.setAccessible(true); + Object securityLock = securityLockField.get(proxy); + String channel = "vp:VotingPlugin"; + JsonEnvelope signed = authenticator.sign(VotingPluginWire.status("backend-a"), + Domain.REDIS_PROXY_BACKEND, "backend-a", channel); + + assertDoesNotThrow(() -> ((VotingPluginProxy) proxy).acceptSharedTransportEnvelope(signed, + Domain.REDIS_PROXY_BACKEND, channel, ignored -> { + CountDownLatch acquired = new CountDownLatch(1); + Thread contender = new Thread(() -> { + synchronized (securityLock) { + acquired.countDown(); + } + }); + contender.start(); + try { + assertTrue(acquired.await(1, TimeUnit.SECONDS)); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new AssertionError(interrupted); + } + })); + } + + @Test + void sharedTransportAuthenticationAndDecryptionUseOnePolicySnapshot(@TempDir Path dataDirectory) + throws Exception { + VotingPluginProxyTestImpl proxy = new VotingPluginProxyTestImpl(); + Path keyFile = dataDirectory.resolve("secretkey.key"); + Files.writeString(keyFile, Base64.getEncoder().encodeToString( + "0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.US_ASCII))); + SharedTransportEnvelopeAuthenticator authenticator = SharedTransportEnvelopeAuthenticator.load(keyFile, + Mode.REQUIRED); + TransportEnvelopeEncryption encryption = TransportEnvelopeEncryption.load(keyFile, + TransportEnvelopeEncryption.Domain.PROXY_BACKEND, true); + setField(proxy, "sharedTransportAuthenticator", authenticator); + setField(proxy, "communicationEncryption", encryption); + String channel = "vp:VotingPlugin"; + JsonEnvelope original = VotingPluginWire.status("backend-a"); + JsonEnvelope signed = authenticator.sign(encryption.encrypt(original), Domain.REDIS_PROXY_BACKEND, + "backend-a", channel); + @SuppressWarnings("unchecked") + Consumer accepted = mock(Consumer.class); + + ((VotingPluginProxy) proxy).acceptSharedTransportEnvelope(signed, Domain.REDIS_PROXY_BACKEND, channel, + accepted); + + ArgumentCaptor delivered = ArgumentCaptor.forClass(JsonEnvelope.class); + verify(accepted).accept(delivered.capture()); + assertEquals(original.getSubChannel(), delivered.getValue().getSubChannel()); + assertEquals(original.getFields(), delivered.getValue().getFields()); + } + + @Test + void softReloadAppliesRequiredSharedTransportAuthentication(@TempDir Path dataDirectory) throws Exception { + VotingPluginProxyTestImpl proxy = new VotingPluginProxyTestImpl(); + proxy.setDataFolder(dataDirectory.toFile()); + Path keyFile = dataDirectory.resolve("secretkey.key"); + Files.writeString(keyFile, Base64.getEncoder().encodeToString( + "0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.US_ASCII))); + when(proxy.getConfig().getBungeeMethod()).thenReturn("REDIS"); + when(proxy.getConfig().getSharedTransportAuthentication()).thenReturn("REQUIRED"); + when(proxy.getConfig().getCommunicationEncryption()).thenReturn(true); + Field authentication = VotingPluginProxy.class.getDeclaredField("sharedTransportAuthenticator"); + authentication.setAccessible(true); + authentication.set(proxy, SharedTransportEnvelopeAuthenticator.load(keyFile, Mode.COMPATIBILITY)); + Field encryption = VotingPluginProxy.class.getDeclaredField("communicationEncryption"); + encryption.setAccessible(true); + encryption.set(proxy, TransportEnvelopeEncryption.load(keyFile, + TransportEnvelopeEncryption.Domain.PROXY_BACKEND, false)); + + proxy.reloadFromControl(); + + SharedTransportEnvelopeAuthenticator reloaded = (SharedTransportEnvelopeAuthenticator) authentication.get(proxy); + assertEquals(Mode.REQUIRED, reloaded.mode()); + assertEquals(SharedTransportEnvelopeAuthenticator.Rejection.MISSING, + reloaded.verify(VotingPluginWire.status("backend-a"), Domain.REDIS_PROXY_BACKEND, "test-channel").rejection()); + assertTrue(((TransportEnvelopeEncryption) encryption.get(proxy)).enabled()); + } + + @Test + void softReloadRetainsEquivalentAuthenticatorReplayState(@TempDir Path dataDirectory) throws Exception { + VotingPluginProxyTestImpl proxy = new VotingPluginProxyTestImpl(); + proxy.setDataFolder(dataDirectory.toFile()); + Path keyFile = dataDirectory.resolve("secretkey.key"); + Files.writeString(keyFile, Base64.getEncoder().encodeToString( + "0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.US_ASCII))); + when(proxy.getConfig().getBungeeMethod()).thenReturn("REDIS"); + when(proxy.getConfig().getSharedTransportAuthentication()).thenReturn("REQUIRED"); + when(proxy.getConfig().getCommunicationEncryption()).thenReturn(true); + SharedTransportEnvelopeAuthenticator original = SharedTransportEnvelopeAuthenticator.load(keyFile, Mode.REQUIRED); + String channel = "vp:VotingPlugin"; + JsonEnvelope signed = original.sign(VotingPluginWire.status("backend-a"), + Domain.REDIS_PROXY_BACKEND, "backend-a", channel); + assertTrue(original.verify(signed, Domain.REDIS_PROXY_BACKEND, channel).accepted()); + Field authentication = VotingPluginProxy.class.getDeclaredField("sharedTransportAuthenticator"); + authentication.setAccessible(true); + authentication.set(proxy, original); + Field encryption = VotingPluginProxy.class.getDeclaredField("communicationEncryption"); + encryption.setAccessible(true); + encryption.set(proxy, TransportEnvelopeEncryption.load(keyFile, + TransportEnvelopeEncryption.Domain.PROXY_BACKEND, true)); + + proxy.reloadFromControl(); + + assertSame(original, authentication.get(proxy)); + assertEquals(SharedTransportEnvelopeAuthenticator.Rejection.REPLAY, + original.verify(signed, Domain.REDIS_PROXY_BACKEND, channel).rejection()); + } + @Test void completionAckTransitionsThroughDurableReceiptRelease(@TempDir Path directory) throws Exception { VotingPluginProxyTestImpl proxy = new VotingPluginProxyTestImpl(); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/multiproxy/MultiProxyHandlerLifecycleTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/multiproxy/MultiProxyHandlerLifecycleTest.java index d3eb66581..98938875b 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/multiproxy/MultiProxyHandlerLifecycleTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/multiproxy/MultiProxyHandlerLifecycleTest.java @@ -10,8 +10,10 @@ import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Base64; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -24,6 +26,10 @@ import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope; import com.bencodez.votingplugin.proxy.VoteTotalsSnapshot; import com.bencodez.votingplugin.proxy.VotingPluginWire; +import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator; +import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator.Domain; +import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator.Mode; +import com.bencodez.votingplugin.proxy.security.TransportEnvelopeEncryption; class MultiProxyHandlerLifecycleTest { @@ -68,10 +74,14 @@ void acceptsAQueuedSocketSendWhenTheConfiguredDestinationReturnsNormally() throw } @Test - void redisSubsetSendPreservesConfiguredChannelCasing() throws Exception { + void redisSubsetSendPreservesConfiguredChannelCasingAndAppliesPrefixOnce(@TempDir Path dataDirectory) throws Exception { MultiProxyHandler handler = mock(MultiProxyHandler.class, org.mockito.Mockito.CALLS_REAL_METHODS); org.mockito.Mockito.when(handler.getMultiProxyMethod()).thenReturn(MultiProxyMethod.REDIS); org.mockito.Mockito.when(handler.getProxyServers()).thenReturn(List.of("Proxy2")); + org.mockito.Mockito.when(handler.getMultiProxyServerName()).thenReturn("Proxy1"); + org.mockito.Mockito.when(handler.getRedisPrefix()).thenReturn("network-a:"); + SharedTransportEnvelopeAuthenticator authenticator = authenticator(dataDirectory); + org.mockito.Mockito.when(handler.getSharedTransportAuthenticator()).thenReturn(authenticator); com.bencodez.simpleapi.servercomm.redis.RedisHandler redis = mock(com.bencodez.simpleapi.servercomm.redis.RedisHandler.class); java.lang.reflect.Field connection = MultiProxyHandler.class.getDeclaredField("multiProxyRedis"); @@ -82,7 +92,340 @@ void redisSubsetSendPreservesConfiguredChannelCasing() throws Exception { assertEquals(java.util.Set.of("Proxy2"), handler.getConfiguredMultiProxyVoteRecipients()); assertTrue(handler.sendMultiProxyEnvelopeAccepted(envelope, List.of("proxy2"))); - verify(redis).publishEnvelope("VotingPluginProxy_Proxy2", envelope); + org.mockito.ArgumentCaptor sent = org.mockito.ArgumentCaptor.forClass(JsonEnvelope.class); + verify(redis).publishEnvelope(org.mockito.ArgumentMatchers.eq("network-a:VotingPluginProxy_Proxy2"), sent.capture()); + assertTrue(authenticator.verify(sent.getValue(), Domain.REDIS_MULTI_PROXY, "network-a:VotingPluginProxy_Proxy2").accepted()); + } + + @Test + void encryptedMultiProxyRedisEnvelopeIsSignedOutsideAndDecryptsToOriginal(@TempDir Path dataDirectory) + throws Exception { + MultiProxyHandler handler = mock(MultiProxyHandler.class, org.mockito.Mockito.CALLS_REAL_METHODS); + org.mockito.Mockito.when(handler.getMultiProxyMethod()).thenReturn(MultiProxyMethod.REDIS); + org.mockito.Mockito.when(handler.getProxyServers()).thenReturn(List.of("Proxy2")); + org.mockito.Mockito.when(handler.getMultiProxyServerName()).thenReturn("Proxy1"); + org.mockito.Mockito.when(handler.getRedisPrefix()).thenReturn("network-a:"); + SharedTransportEnvelopeAuthenticator authenticator = authenticator(dataDirectory); + org.mockito.Mockito.when(handler.getSharedTransportAuthenticator()).thenReturn(authenticator); + TransportEnvelopeEncryption encryption = TransportEnvelopeEncryption.load( + dataDirectory.resolve("secretkey.key"), TransportEnvelopeEncryption.Domain.MULTI_PROXY, true); + java.lang.reflect.Field cipher = MultiProxyHandler.class.getDeclaredField("communicationEncryption"); + cipher.setAccessible(true); + cipher.set(handler, encryption); + com.bencodez.simpleapi.servercomm.redis.RedisHandler redis = + mock(com.bencodez.simpleapi.servercomm.redis.RedisHandler.class); + java.lang.reflect.Field connection = MultiProxyHandler.class.getDeclaredField("multiProxyRedis"); + connection.setAccessible(true); + connection.set(handler, redis); + JsonEnvelope original = JsonEnvelope.builder("vote").put("player", "Alex").build(); + + assertTrue(handler.sendMultiProxyEnvelopeAccepted(original, List.of("Proxy2"))); + + org.mockito.ArgumentCaptor sent = org.mockito.ArgumentCaptor.forClass(JsonEnvelope.class); + verify(redis).publishEnvelope(org.mockito.ArgumentMatchers.eq("network-a:VotingPluginProxy_Proxy2"), sent.capture()); + JsonEnvelope authenticated = authenticator.verify(sent.getValue(), Domain.REDIS_MULTI_PROXY, "network-a:VotingPluginProxy_Proxy2").envelope(); + TransportEnvelopeEncryption.Decryption decrypted = encryption.decrypt(authenticated); + assertTrue(decrypted.accepted()); + assertEquals(original.getSubChannel(), decrypted.envelope().getSubChannel()); + assertEquals(original.getFields(), decrypted.envelope().getFields()); + } + + @Test + void reusedRedisConnectionSubscribesToTheSameSinglePrefixedChannel(@TempDir Path dataDirectory) throws Exception { + MultiProxyHandler handler = mock(MultiProxyHandler.class, + org.mockito.Mockito.withSettings().useConstructor().defaultAnswer(org.mockito.Mockito.CALLS_REAL_METHODS)); + com.bencodez.simpleapi.servercomm.redis.RedisHandler redis = + mock(com.bencodez.simpleapi.servercomm.redis.RedisHandler.class); + com.bencodez.simpleapi.servercomm.redis.RedisListener listener = + mock(com.bencodez.simpleapi.servercomm.redis.RedisListener.class); + org.mockito.Mockito.when(handler.getMultiProxySupportEnabled()).thenReturn(true); + org.mockito.Mockito.when(handler.getMultiProxyMethod()).thenReturn(MultiProxyMethod.REDIS); + org.mockito.Mockito.when(handler.getMultiProxyRedisUseExistingConnection()).thenReturn(true); + org.mockito.Mockito.when(handler.getRedisHandler()).thenReturn(redis); + org.mockito.Mockito.when(handler.getRedisPrefix()).thenReturn("network-a:"); + org.mockito.Mockito.when(handler.getMultiProxyServerName()).thenReturn("Proxy1"); + org.mockito.Mockito.when(handler.getMultiProxyServers()).thenReturn(List.of()); + org.mockito.Mockito.when(handler.getSharedTransportAuthenticator()).thenReturn(authenticator(dataDirectory)); + org.mockito.Mockito.when(redis.createEnvelopeListener(org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.any())).thenReturn(listener); + org.mockito.Mockito.doAnswer(invocation -> { + ((Runnable) invocation.getArgument(0)).run(); + return null; + }).when(handler).runAsnc(org.mockito.ArgumentMatchers.any()); + + handler.loadMultiProxySupport(); + + verify(redis).createEnvelopeListener(org.mockito.ArgumentMatchers.eq("network-a:VotingPluginProxy_Proxy1"), + org.mockito.ArgumentMatchers.any()); + verify(redis).loadListener(listener); + } + + @Test + void compatibilityModeBridgesPrefixedAndLegacyMultiProxyChannels(@TempDir Path dataDirectory) throws Exception { + MultiProxyHandler handler = mock(MultiProxyHandler.class, + org.mockito.Mockito.withSettings().useConstructor().defaultAnswer(org.mockito.Mockito.CALLS_REAL_METHODS)); + com.bencodez.simpleapi.servercomm.redis.RedisHandler redis = + mock(com.bencodez.simpleapi.servercomm.redis.RedisHandler.class); + com.bencodez.simpleapi.servercomm.redis.RedisListener prefixed = + mock(com.bencodez.simpleapi.servercomm.redis.RedisListener.class); + com.bencodez.simpleapi.servercomm.redis.RedisListener legacy = + mock(com.bencodez.simpleapi.servercomm.redis.RedisListener.class); + org.mockito.Mockito.when(handler.getMultiProxySupportEnabled()).thenReturn(true); + org.mockito.Mockito.when(handler.getMultiProxyMethod()).thenReturn(MultiProxyMethod.REDIS); + org.mockito.Mockito.when(handler.getMultiProxyRedisUseExistingConnection()).thenReturn(true); + org.mockito.Mockito.when(handler.getRedisHandler()).thenReturn(redis); + org.mockito.Mockito.when(handler.getRedisPrefix()).thenReturn("network-a:"); + org.mockito.Mockito.when(handler.getMultiProxyServerName()).thenReturn("Proxy1"); + org.mockito.Mockito.when(handler.getMultiProxyServers()).thenReturn(List.of()); + org.mockito.Mockito.when(handler.getSharedTransportAuthenticator()) + .thenReturn(authenticator(dataDirectory, Mode.COMPATIBILITY)); + org.mockito.Mockito.when(redis.createEnvelopeListener(org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.any())).thenReturn(prefixed, legacy); + org.mockito.Mockito.doAnswer(invocation -> { + ((Runnable) invocation.getArgument(0)).run(); + return null; + }).when(handler).runAsnc(org.mockito.ArgumentMatchers.any()); + + handler.loadMultiProxySupport(); + + verify(redis).createEnvelopeListener(org.mockito.ArgumentMatchers.eq("network-a:VotingPluginProxy_Proxy1"), + org.mockito.ArgumentMatchers.any()); + verify(redis).createEnvelopeListener(org.mockito.ArgumentMatchers.eq("VotingPluginProxy_Proxy1"), + org.mockito.ArgumentMatchers.any()); + verify(redis).loadListener(prefixed); + verify(redis).loadListener(legacy); + } + + @Test + void compatibilityModeStartsBothRedisSubscriptionThreads(@TempDir Path dataDirectory) throws Exception { + MultiProxyHandler handler = mock(MultiProxyHandler.class, + org.mockito.Mockito.withSettings().useConstructor().defaultAnswer(org.mockito.Mockito.CALLS_REAL_METHODS)); + com.bencodez.simpleapi.servercomm.redis.RedisHandler redis = + new com.bencodez.simpleapi.servercomm.redis.RedisHandler("127.0.0.1", 1, "", "", 0) { + @Override public void debug(String message) { } + }; + try { + org.mockito.Mockito.when(handler.getMultiProxySupportEnabled()).thenReturn(true); + org.mockito.Mockito.when(handler.getMultiProxyMethod()).thenReturn(MultiProxyMethod.REDIS); + org.mockito.Mockito.when(handler.getMultiProxyRedisUseExistingConnection()).thenReturn(true); + org.mockito.Mockito.when(handler.getRedisHandler()).thenReturn(redis); + org.mockito.Mockito.when(handler.getRedisPrefix()).thenReturn("network-a:"); + org.mockito.Mockito.when(handler.getMultiProxyServerName()).thenReturn("Proxy1"); + org.mockito.Mockito.when(handler.getMultiProxyServers()).thenReturn(List.of()); + org.mockito.Mockito.when(handler.getSharedTransportAuthenticator()) + .thenReturn(authenticator(dataDirectory, Mode.COMPATIBILITY)); + org.mockito.Mockito.doAnswer(invocation -> { + ((Runnable) invocation.getArgument(0)).run(); + return null; + }).when(handler).runAsnc(org.mockito.ArgumentMatchers.any()); + + handler.loadMultiProxySupport(); + + java.lang.reflect.Field threadsField = redis.getClass().getSuperclass().getDeclaredField("listenerThreads"); + threadsField.setAccessible(true); + @SuppressWarnings("unchecked") + Map threads = + (Map) threadsField.get(redis); + assertEquals(java.util.Set.of("network-a:VotingPluginProxy_Proxy1", "VotingPluginProxy_Proxy1"), + threads.keySet().stream().map(com.bencodez.simpleapi.servercomm.redis.RedisListener::getChannel) + .collect(java.util.stream.Collectors.toSet())); + assertTrue(threads.values().stream().allMatch(Thread::isAlive)); + } finally { + redis.close(); + } + } + + @Test + void compatibilityModePublishesIdenticalSignedEnvelopeOnBothChannelNames(@TempDir Path dataDirectory) + throws Exception { + MultiProxyHandler handler = mock(MultiProxyHandler.class, org.mockito.Mockito.CALLS_REAL_METHODS); + org.mockito.Mockito.when(handler.getMultiProxyMethod()).thenReturn(MultiProxyMethod.REDIS); + org.mockito.Mockito.when(handler.getProxyServers()).thenReturn(List.of("Proxy2")); + org.mockito.Mockito.when(handler.getMultiProxyServerName()).thenReturn("Proxy1"); + org.mockito.Mockito.when(handler.getRedisPrefix()).thenReturn("network-a:"); + org.mockito.Mockito.when(handler.getSharedTransportAuthenticator()) + .thenReturn(authenticator(dataDirectory, Mode.COMPATIBILITY)); + com.bencodez.simpleapi.servercomm.redis.RedisHandler redis = + mock(com.bencodez.simpleapi.servercomm.redis.RedisHandler.class); + java.lang.reflect.Field connection = MultiProxyHandler.class.getDeclaredField("multiProxyRedis"); + connection.setAccessible(true); + connection.set(handler, redis); + + assertTrue(handler.sendMultiProxyEnvelopeAccepted(JsonEnvelope.builder("vote").build(), List.of("Proxy2"))); + + org.mockito.ArgumentCaptor sent = org.mockito.ArgumentCaptor.forClass(JsonEnvelope.class); + verify(redis).publishEnvelope(org.mockito.ArgumentMatchers.eq("network-a:VotingPluginProxy_Proxy2"), + sent.capture()); + verify(redis).publishEnvelope(org.mockito.ArgumentMatchers.eq("VotingPluginProxy_Proxy2"), sent.capture()); + assertEquals(sent.getAllValues().get(0).getFields(), sent.getAllValues().get(1).getFields()); + } + + @Test + void unsignedBridgeCopiesDeduplicateLegacyVoteIdsButNotOriginBoundOrSignedTraffic(@TempDir Path dataDirectory) + throws Exception { + MultiProxyHandler handler = mock(MultiProxyHandler.class, + org.mockito.Mockito.withSettings().useConstructor().defaultAnswer(org.mockito.Mockito.CALLS_REAL_METHODS)); + org.mockito.Mockito.when(handler.getSharedTransportAuthenticator()) + .thenReturn(authenticator(dataDirectory, Mode.COMPATIBILITY)); + org.mockito.Mockito.when(handler.getRedisPrefix()).thenReturn("network-a:"); + org.mockito.Mockito.when(handler.getMultiProxyServerName()).thenReturn("Proxy2"); + String prefixed = "network-a:VotingPluginProxy_Proxy2"; + String legacy = "VotingPluginProxy_Proxy2"; + JsonEnvelope clear = VotingPluginWire.clearVotePrimary("player-uuid", "Player", "Proxy1"); + + handler.acceptRedisEnvelope(clear, prefixed); + handler.acceptRedisEnvelope(clear, legacy); + verify(handler).clearVote("player-uuid"); + handler.acceptRedisEnvelope(clear, prefixed); + verify(handler, org.mockito.Mockito.times(2)).clearVote("player-uuid"); + + JsonEnvelope withVoteId = clear.toBuilder().put(VotingPluginWire.K_VOTE_ID, UUID.randomUUID().toString()).build(); + handler.acceptRedisEnvelope(withVoteId, prefixed); + handler.acceptRedisEnvelope(withVoteId, legacy); + verify(handler, org.mockito.Mockito.times(3)).clearVote("player-uuid"); + + UUID legacyVoteId = UUID.randomUUID(); + JsonEnvelope legacyVote = VotingPluginWire.vote("Player", + "00000000-0000-0000-0000-000000000001", "Service", 1L, true, true, "", legacyVoteId, + true, false, 1, 1); + handler.acceptRedisEnvelope(legacyVote, prefixed); + handler.acceptRedisEnvelope(legacyVote, legacy); + verify(handler).triggerVote(org.mockito.ArgumentMatchers.eq("Player"), + org.mockito.ArgumentMatchers.eq("Service"), org.mockito.ArgumentMatchers.eq(true), + org.mockito.ArgumentMatchers.eq(true), org.mockito.ArgumentMatchers.eq(0L), + org.mockito.ArgumentMatchers.any(VoteTotalsSnapshot.class), + org.mockito.ArgumentMatchers.eq("00000000-0000-0000-0000-000000000001")); + + UUID reliableVoteId = UUID.randomUUID(); + JsonEnvelope reliableVote = VotingPluginWire.multiProxyVote("Player", + "00000000-0000-0000-0000-000000000001", "Service", 1L, true, true, "", reliableVoteId, + true, false, 1, 1, "Proxy1"); + handler.acceptRedisEnvelope(reliableVote, prefixed); + handler.acceptRedisEnvelope(reliableVote, legacy); + verify(handler, org.mockito.Mockito.times(2)).triggerVote(org.mockito.ArgumentMatchers.eq("Player"), + org.mockito.ArgumentMatchers.eq("Service"), org.mockito.ArgumentMatchers.eq(true), + org.mockito.ArgumentMatchers.eq(true), org.mockito.ArgumentMatchers.eq(0L), + org.mockito.ArgumentMatchers.any(VoteTotalsSnapshot.class), + org.mockito.ArgumentMatchers.eq("00000000-0000-0000-0000-000000000001"), + org.mockito.ArgumentMatchers.eq(reliableVoteId), org.mockito.ArgumentMatchers.eq("Proxy1")); + + SharedTransportEnvelopeAuthenticator signer = authenticator(dataDirectory); + handler.acceptRedisEnvelope(signer.sign(clear, Domain.REDIS_MULTI_PROXY, "Proxy1", "network-a:VotingPluginProxy_Proxy2"), prefixed); + handler.acceptRedisEnvelope(signer.sign(clear, Domain.REDIS_MULTI_PROXY, "Proxy1", legacy), legacy); + verify(handler, org.mockito.Mockito.times(5)).clearVote("player-uuid"); + } + + @Test + void unsignedBridgeWindowExpiresAndRetainsAtMostItsBound(@TempDir Path dataDirectory) throws Exception { + MultiProxyHandler handler = mock(MultiProxyHandler.class, + org.mockito.Mockito.withSettings().useConstructor().defaultAnswer(org.mockito.Mockito.CALLS_REAL_METHODS)); + org.mockito.Mockito.when(handler.getSharedTransportAuthenticator()) + .thenReturn(authenticator(dataDirectory, Mode.COMPATIBILITY)); + org.mockito.Mockito.when(handler.getRedisPrefix()).thenReturn("network-a:"); + org.mockito.Mockito.when(handler.getMultiProxyServerName()).thenReturn("Proxy2"); + java.util.concurrent.atomic.AtomicLong now = new java.util.concurrent.atomic.AtomicLong(1L); + org.mockito.Mockito.doAnswer(ignored -> now.get()).when(handler).unsignedBridgeNowNanos(); + String prefixed = "network-a:VotingPluginProxy_Proxy2"; + String legacy = "VotingPluginProxy_Proxy2"; + JsonEnvelope clear = VotingPluginWire.clearVotePrimary("player-uuid", "Player", "Proxy1"); + java.util.concurrent.atomic.AtomicBoolean slowFirstCopy = new java.util.concurrent.atomic.AtomicBoolean(true); + org.mockito.Mockito.doAnswer(ignored -> { + if (slowFirstCopy.compareAndSet(true, false)) + now.addAndGet(java.util.concurrent.TimeUnit.SECONDS.toNanos(3)); + return null; + }).when(handler).clearVote("player-uuid"); + + handler.acceptRedisEnvelope(clear, prefixed); + handler.acceptRedisEnvelope(clear, legacy); + verify(handler).clearVote("player-uuid"); + now.addAndGet(java.util.concurrent.TimeUnit.SECONDS.toNanos(3)); + handler.acceptRedisEnvelope(clear, legacy); + verify(handler, org.mockito.Mockito.times(2)).clearVote("player-uuid"); + + for (int index = 0; index < 1100; index++) { + handler.acceptRedisEnvelope(VotingPluginWire.clearVotePrimary("player-" + index, "Player", "Proxy1"), + prefixed); + } + java.lang.reflect.Field entries = MultiProxyHandler.class.getDeclaredField("unsignedBridgeCopies"); + entries.setAccessible(true); + assertTrue(((Map) entries.get(handler)).size() <= 1024); + } + + @Test + void unsignedMultiProxyVoteAndForgedAcknowledgementCannotMutateState(@TempDir Path dataDirectory) throws Exception { + MultiProxyHandler handler = mock(MultiProxyHandler.class, + org.mockito.Mockito.withSettings().useConstructor().defaultAnswer(org.mockito.Mockito.CALLS_REAL_METHODS)); + org.mockito.Mockito.when(handler.getSharedTransportAuthenticator()).thenReturn(authenticator(dataDirectory)); + org.mockito.Mockito.when(handler.getMultiProxyServerName()).thenReturn("Primary"); + UUID voteId = UUID.randomUUID(); + + handler.acceptRedisEnvelope(VotingPluginWire.multiProxyVote("Player", + "00000000-0000-0000-0000-000000000001", "Service", 1L, true, true, "", voteId, + true, false, 1, 1, "Replica")); + handler.acceptRedisEnvelope(VotingPluginWire.multiProxyVoteAck(voteId, "Primary", "Replica")); + + verify(handler, org.mockito.Mockito.never()).triggerVote(org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyBoolean(), + org.mockito.ArgumentMatchers.anyBoolean(), org.mockito.ArgumentMatchers.anyLong(), + org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.any(UUID.class), org.mockito.ArgumentMatchers.anyString()); + verify(handler, org.mockito.Mockito.never()).onMultiProxyVoteAcknowledged( + org.mockito.ArgumentMatchers.any(UUID.class), org.mockito.ArgumentMatchers.anyString()); + } + + @Test + void authenticatedMultiProxyVoteIsAcceptedOnlyOnce(@TempDir Path dataDirectory) throws Exception { + MultiProxyHandler handler = mock(MultiProxyHandler.class, + org.mockito.Mockito.withSettings().useConstructor().defaultAnswer(org.mockito.Mockito.CALLS_REAL_METHODS)); + SharedTransportEnvelopeAuthenticator authenticator = authenticator(dataDirectory); + org.mockito.Mockito.when(handler.getSharedTransportAuthenticator()).thenReturn(authenticator); + UUID voteId = UUID.randomUUID(); + JsonEnvelope signed = authenticator.sign(VotingPluginWire.multiProxyVote("Player", + "00000000-0000-0000-0000-000000000001", "Service", 1L, true, true, "", voteId, + true, false, 1, 1, "Replica"), Domain.REDIS_MULTI_PROXY, "Replica", "network-a:VotingPluginProxy_Proxy2"); + + handler.acceptRedisEnvelope(signed, "network-a:VotingPluginProxy_Proxy2"); + handler.acceptRedisEnvelope(signed, "network-a:VotingPluginProxy_Proxy2"); + + verify(handler, org.mockito.Mockito.times(1)).triggerVote(org.mockito.ArgumentMatchers.eq("Player"), + org.mockito.ArgumentMatchers.eq("Service"), org.mockito.ArgumentMatchers.eq(true), + org.mockito.ArgumentMatchers.eq(true), org.mockito.ArgumentMatchers.eq(0L), + org.mockito.ArgumentMatchers.any(VoteTotalsSnapshot.class), + org.mockito.ArgumentMatchers.eq("00000000-0000-0000-0000-000000000001"), + org.mockito.ArgumentMatchers.eq(voteId), org.mockito.ArgumentMatchers.eq("Replica")); + } + + @Test + void retiredReusedRedisCallbackCannotAcceptMessagesAfterReload(@TempDir Path dataDirectory) throws Exception { + MultiProxyHandler retired = mock(MultiProxyHandler.class, + org.mockito.Mockito.withSettings().useConstructor().defaultAnswer(org.mockito.Mockito.CALLS_REAL_METHODS)); + SharedTransportEnvelopeAuthenticator authenticator = authenticator(dataDirectory); + org.mockito.Mockito.when(retired.getSharedTransportAuthenticator()).thenReturn(authenticator); + String channel = "network-a:VotingPluginProxy_Proxy2"; + JsonEnvelope signed = authenticator.sign( + VotingPluginWire.clearVotePrimary("player-uuid", "Player", "Proxy1"), + Domain.REDIS_MULTI_PROXY, "Proxy1", channel); + + retired.close(); + retired.acceptRedisEnvelope(signed, channel); + + verify(retired, org.mockito.Mockito.never()).clearVote("player-uuid"); + } + + @Test + void copiedMultiProxyMessageCannotAuthenticateOnAnotherRecipientChannel(@TempDir Path dataDirectory) + throws Exception { + MultiProxyHandler handler = mock(MultiProxyHandler.class, + org.mockito.Mockito.withSettings().useConstructor().defaultAnswer(org.mockito.Mockito.CALLS_REAL_METHODS)); + SharedTransportEnvelopeAuthenticator authenticator = authenticator(dataDirectory); + org.mockito.Mockito.when(handler.getSharedTransportAuthenticator()).thenReturn(authenticator); + JsonEnvelope signed = authenticator.sign(VotingPluginWire.clearVotePrimary("player-uuid", "Player", "Proxy1"), + Domain.REDIS_MULTI_PROXY, "Proxy1", "network-a:VotingPluginProxy_ProxyA"); + + handler.acceptRedisEnvelope(signed, "network-a:VotingPluginProxy_ProxyB"); + verify(handler, org.mockito.Mockito.never()).clearVote("player-uuid"); + handler.acceptRedisEnvelope(signed, "network-a:VotingPluginProxy_ProxyA"); + verify(handler).clearVote("player-uuid"); } @Test @@ -480,6 +823,17 @@ private static MultiProxyHandler capabilityHandler(Path dataDirectory, String... return handler; } + private static SharedTransportEnvelopeAuthenticator authenticator(Path dataDirectory) throws Exception { + return authenticator(dataDirectory, Mode.REQUIRED); + } + + private static SharedTransportEnvelopeAuthenticator authenticator(Path dataDirectory, Mode mode) throws Exception { + Path keyFile = dataDirectory.resolve("secretkey.key"); + Files.writeString(keyFile, Base64.getEncoder().encodeToString( + "0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.US_ASCII))); + return SharedTransportEnvelopeAuthenticator.load(keyFile, mode); + } + private static void handleCapability(MultiProxyHandler handler, String peer) throws Exception { Method handleEnvelope = MultiProxyHandler.class.getDeclaredMethod("handleEnvelope", JsonEnvelope.class); handleEnvelope.setAccessible(true); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/redis/VotingPluginRedisChannelsTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/redis/VotingPluginRedisChannelsTest.java new file mode 100644 index 000000000..9b186705e --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/redis/VotingPluginRedisChannelsTest.java @@ -0,0 +1,24 @@ +package com.bencodez.votingplugin.proxy.redis; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +import org.junit.jupiter.api.Test; + +class VotingPluginRedisChannelsTest { + @Test + void separatesMultiProxyNetworksByConfiguredPrefix() { + String networkA = VotingPluginRedisChannels.multiProxy("network-a:", "proxy-1"); + String networkB = VotingPluginRedisChannels.multiProxy("network-b:", "proxy-1"); + + assertEquals("network-a:VotingPluginProxy_proxy-1", networkA); + assertNotEquals(networkA, networkB); + } + + @Test + void appliesPrefixExactlyOnceForEveryChannelRole() { + assertEquals("vp:VotingPlugin", VotingPluginRedisChannels.proxy("vp:")); + assertEquals("vp:VotingPlugin_backend-a", VotingPluginRedisChannels.backend("vp:", "backend-a")); + assertEquals("vp:VotingPluginProxy_proxy-a", VotingPluginRedisChannels.multiProxy("vp:", "proxy-a")); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/security/SharedTransportEnvelopeAuthenticatorTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/security/SharedTransportEnvelopeAuthenticatorTest.java new file mode 100644 index 000000000..52ea583aa --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/security/SharedTransportEnvelopeAuthenticatorTest.java @@ -0,0 +1,281 @@ +package com.bencodez.votingplugin.proxy.security; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.concurrent.atomic.AtomicLong; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope; +import com.bencodez.votingplugin.proxy.VotingPluginWire; +import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator.Domain; +import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator.Mode; +import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator.Rejection; + +class SharedTransportEnvelopeAuthenticatorTest { + private static final byte[] KEY = "0123456789abcdef0123456789abcdef".getBytes(java.nio.charset.StandardCharsets.US_ASCII); + private static final Clock CLOCK = Clock.fixed(Instant.ofEpochMilli(1_800_000_000_000L), ZoneOffset.UTC); + + @Test + void missingSettingDefaultsToUpgradeSafeCompatibility() { + assertEquals(Mode.COMPATIBILITY, Mode.parse(null)); + assertEquals(Mode.COMPATIBILITY, Mode.parse(" ")); + assertEquals(Mode.REQUIRED, Mode.parse("REQUIRED")); + assertThrows(IllegalArgumentException.class, () -> Mode.parse("COMPATIBLE")); + assertThrows(IllegalArgumentException.class, () -> Mode.parse("invalid")); + } + + @Test + void authenticatesAndStripsTransportMetadata() { + SharedTransportEnvelopeAuthenticator authenticator = authenticator(); + JsonEnvelope original = JsonEnvelope.builder(VotingPluginWire.SUB_VOTE).put("player", "Alex") + .put(VotingPluginWire.K_VOTE_ID, "00000000-0000-0000-0000-000000000001").build(); + + JsonEnvelope signed = authenticator.sign(original, Domain.REDIS_PROXY_BACKEND, "proxy-a", "test-channel"); + SharedTransportEnvelopeAuthenticator.Verification result = authenticator.verify(signed, + Domain.REDIS_PROXY_BACKEND, "test-channel"); + + assertTrue(result.accepted()); + assertEquals(original.getFields(), result.envelope().getFields()); + assertNull(result.envelope().getFields().get(SharedTransportEnvelopeAuthenticator.K_MAC)); + } + + @Test + void rejectsPayloadAndSubchannelTampering() { + SharedTransportEnvelopeAuthenticator authenticator = authenticator(); + JsonEnvelope signed = authenticator.sign(JsonEnvelope.builder(VotingPluginWire.SUB_VOTE) + .put("player", "Alex").build(), Domain.REDIS_PROXY_BACKEND, "proxy-a", "test-channel"); + + JsonEnvelope payloadChanged = signed.toBuilder().put("player", "Mallory").build(); + JsonEnvelope subchannelChanged = copyAs(signed, VotingPluginWire.SUB_VOTE_UPDATE); + JsonEnvelope senderChanged = signed.toBuilder().put(SharedTransportEnvelopeAuthenticator.K_SENDER, + "proxy-b").build(); + + assertEquals(Rejection.INVALID, authenticator.verify(payloadChanged, Domain.REDIS_PROXY_BACKEND, "test-channel").rejection()); + assertEquals(Rejection.INVALID, authenticator.verify(subchannelChanged, Domain.REDIS_PROXY_BACKEND, "test-channel").rejection()); + assertEquals(Rejection.INVALID, authenticator.verify(senderChanged, Domain.REDIS_PROXY_BACKEND, "test-channel").rejection()); + } + + @Test + void domainSeparatedMacCannotAuthenticateMultiProxyTraffic() { + SharedTransportEnvelopeAuthenticator authenticator = authenticator(); + JsonEnvelope signed = authenticator.sign(JsonEnvelope.builder(VotingPluginWire.SUB_VOTE).build(), + Domain.REDIS_PROXY_BACKEND, "proxy-a", "test-channel"); + + assertEquals(Rejection.INVALID, authenticator.verify(signed, Domain.REDIS_MULTI_PROXY, "test-channel").rejection()); + assertEquals(Rejection.INVALID, authenticator.verify(signed, Domain.MQTT_PROXY_BACKEND, "test-channel").rejection()); + } + + @Test + void signedMessagesAreBoundToEachBrokerDestination() { + for (Domain domain : Domain.values()) { + SharedTransportEnvelopeAuthenticator authenticator = authenticator(); + JsonEnvelope original = JsonEnvelope.builder(VotingPluginWire.SUB_VOTE) + .put(VotingPluginWire.K_VOTE_ID, "00000000-0000-0000-0000-000000000001").build(); + JsonEnvelope signed = authenticator.sign(original, domain, "proxy-a", "channel-a"); + + assertEquals(Rejection.INVALID, authenticator.verify(signed, domain, "channel-b").rejection()); + assertEquals(0, authenticator.replayEntryCount()); + assertEquals(original.getFields(), authenticator.verify(signed, domain, "channel-a").envelope().getFields()); + assertEquals("2", signed.getFields().get(SharedTransportEnvelopeAuthenticator.K_VERSION)); + } + } + + @Test + void rejectsAuthenticatedEnvelopeOutsideTheFreshnessWindow() { + SharedTransportEnvelopeAuthenticator signer = authenticator(); + SharedTransportEnvelopeAuthenticator laterVerifier = SharedTransportEnvelopeAuthenticator.forTesting(KEY, + Mode.REQUIRED, Clock.offset(CLOCK, + java.time.Duration.ofMillis(SharedTransportEnvelopeAuthenticator.MAX_CLOCK_SKEW_MILLIS + 1))); + JsonEnvelope signed = signer.sign(JsonEnvelope.builder(VotingPluginWire.SUB_STATUS).build(), + Domain.REDIS_PROXY_BACKEND, "backend-a", "test-channel"); + + assertEquals(Rejection.STALE, laterVerifier.verify(signed, Domain.REDIS_PROXY_BACKEND, "test-channel").rejection()); + } + + @Test + void rejectsExactReplayWithoutGrowingUnbounded() { + int testCapacity = 64; + SharedTransportEnvelopeAuthenticator authenticator = SharedTransportEnvelopeAuthenticator.forTesting(KEY, + Mode.REQUIRED, CLOCK, testCapacity); + JsonEnvelope signed = authenticator.sign(JsonEnvelope.builder(VotingPluginWire.SUB_LOGIN).build(), + Domain.REDIS_PROXY_BACKEND, "backend-a", "test-channel"); + + assertTrue(authenticator.verify(signed, Domain.REDIS_PROXY_BACKEND, "test-channel").accepted()); + assertEquals(Rejection.REPLAY, authenticator.verify(signed, Domain.REDIS_PROXY_BACKEND, "test-channel").rejection()); + assertEquals(1, authenticator.replayEntryCount()); + + for (int index = 1; index < testCapacity; index++) { + JsonEnvelope next = authenticator.sign(JsonEnvelope.builder(VotingPluginWire.SUB_LOGIN) + .put("sequence", index).build(), Domain.REDIS_PROXY_BACKEND, "backend-a", "test-channel"); + assertTrue(authenticator.verify(next, Domain.REDIS_PROXY_BACKEND, "test-channel").accepted()); + } + JsonEnvelope overflow = authenticator.sign(JsonEnvelope.builder(VotingPluginWire.SUB_LOGIN) + .put("sequence", "overflow").build(), Domain.REDIS_PROXY_BACKEND, "backend-a", "test-channel"); + assertEquals(Rejection.CAPACITY, authenticator.verify(overflow, Domain.REDIS_PROXY_BACKEND, "test-channel").rejection()); + assertEquals(testCapacity, authenticator.replayEntryCount()); + } + + @Test + void retainsFutureDatedNonceForItsEntireAcceptanceWindow() { + long base = CLOCK.millis(); + MutableClock clock = new MutableClock(base + SharedTransportEnvelopeAuthenticator.MAX_CLOCK_SKEW_MILLIS); + SharedTransportEnvelopeAuthenticator authenticator = SharedTransportEnvelopeAuthenticator.forTesting(KEY, + Mode.REQUIRED, clock); + JsonEnvelope signed = authenticator.sign(JsonEnvelope.builder(VotingPluginWire.SUB_LOGIN).build(), + Domain.REDIS_PROXY_BACKEND, "backend-a", "test-channel"); + + clock.set(base); + assertTrue(authenticator.verify(signed, Domain.REDIS_PROXY_BACKEND, "test-channel").accepted()); + clock.set(base + SharedTransportEnvelopeAuthenticator.MAX_CLOCK_SKEW_MILLIS + 1L); + assertEquals(Rejection.REPLAY, authenticator.verify(signed, Domain.REDIS_PROXY_BACKEND, "test-channel").rejection()); + clock.set(base + 2L * SharedTransportEnvelopeAuthenticator.MAX_CLOCK_SKEW_MILLIS + 1L); + assertEquals(Rejection.STALE, authenticator.verify(signed, Domain.REDIS_PROXY_BACKEND, "test-channel").rejection()); + } + + @Test + void expiryPruningInspectsOnlyExpiredEntriesAndTheNextLiveEntry() { + long base = CLOCK.millis(); + MutableClock signerClock = new MutableClock(base + SharedTransportEnvelopeAuthenticator.MAX_CLOCK_SKEW_MILLIS); + MutableClock verifierClock = new MutableClock(base); + SharedTransportEnvelopeAuthenticator signer = SharedTransportEnvelopeAuthenticator.forTesting(KEY, + Mode.REQUIRED, signerClock); + SharedTransportEnvelopeAuthenticator verifier = SharedTransportEnvelopeAuthenticator.forTesting(KEY, + Mode.REQUIRED, verifierClock, 4_098); + JsonEnvelope vote = JsonEnvelope.builder(VotingPluginWire.SUB_VOTE).build(); + JsonEnvelope firstLive = null; + for (int index = 0; index < 4_096; index++) { + JsonEnvelope signed = signer.sign(vote, Domain.REDIS_PROXY_BACKEND, "proxy-a", "test-channel"); + if (firstLive == null) firstLive = signed; + assertTrue(verifier.verify(signed, Domain.REDIS_PROXY_BACKEND, "test-channel").accepted()); + } + signerClock.set(base - SharedTransportEnvelopeAuthenticator.MAX_CLOCK_SKEW_MILLIS); + for (int index = 0; index < 2; index++) { + JsonEnvelope signed = signer.sign(vote, Domain.REDIS_PROXY_BACKEND, "proxy-a", "test-channel"); + assertTrue(verifier.verify(signed, Domain.REDIS_PROXY_BACKEND, "test-channel").accepted()); + } + assertEquals(4_098, verifier.replayEntryCount()); + + verifierClock.set(base + 1); + assertEquals(3, verifier.pruneExpired(verifierClock.millis())); + assertEquals(4_096, verifier.replayEntryCount()); + assertEquals(1, verifier.pruneExpired(verifierClock.millis())); + assertEquals(Rejection.REPLAY, + verifier.verify(firstLive, Domain.REDIS_PROXY_BACKEND, "test-channel").rejection()); + signerClock.set(base + 1); + assertTrue(verifier.verify(signer.sign(vote, Domain.REDIS_PROXY_BACKEND, "proxy-a", "test-channel"), + Domain.REDIS_PROXY_BACKEND, "test-channel").accepted()); + } + + @Test + void compatibilityModeAcceptsOnlyWhollyUnsignedLegacyEnvelope() { + SharedTransportEnvelopeAuthenticator compatibility = SharedTransportEnvelopeAuthenticator.forTesting(KEY, + Mode.COMPATIBILITY, CLOCK); + JsonEnvelope unsigned = JsonEnvelope.builder(VotingPluginWire.SUB_STATUS).build(); + JsonEnvelope partiallySigned = unsigned.toBuilder() + .put(SharedTransportEnvelopeAuthenticator.K_VERSION, "1").build(); + + assertTrue(compatibility.verify(unsigned, Domain.REDIS_PROXY_BACKEND, "test-channel").unsignedCompatibility()); + assertFalse(compatibility.verify(partiallySigned, Domain.REDIS_PROXY_BACKEND, "test-channel").accepted()); + } + + @Test + void compatibilityModeKeepsOutboundTrafficUnsignedUntilRequired() { + SharedTransportEnvelopeAuthenticator compatibility = SharedTransportEnvelopeAuthenticator.forTesting(KEY, + Mode.COMPATIBILITY, CLOCK); + JsonEnvelope original = JsonEnvelope.builder(VotingPluginWire.SUB_STATUS).put("server", "backend-a").build(); + + JsonEnvelope outbound = compatibility.sign(original, Domain.REDIS_PROXY_BACKEND, "backend-a", "test-channel"); + + assertEquals(original.getFields(), outbound.getFields()); + assertFalse(outbound.getFields().containsKey(SharedTransportEnvelopeAuthenticator.K_MAC)); + assertEquals(original, compatibility.sign(original, Domain.REDIS_PROXY_BACKEND, null, "test-channel")); + assertEquals(original, compatibility.sign(original, Domain.REDIS_PROXY_BACKEND, "", "test-channel")); + assertEquals(original, compatibility.sign(original, Domain.REDIS_PROXY_BACKEND, "x".repeat(129), "test-channel")); + assertThrows(IllegalArgumentException.class, + () -> authenticator().sign(original, Domain.REDIS_PROXY_BACKEND, "", "test-channel")); + } + + @Test + void onlyRequiredModeNeedsAProvisionedKey(@TempDir Path dataDirectory) throws Exception { + assertThrows(java.io.IOException.class, () -> SharedTransportEnvelopeAuthenticator.load( + dataDirectory.resolve("missing-secretkey.key"), Mode.REQUIRED)); + SharedTransportEnvelopeAuthenticator compatibility = SharedTransportEnvelopeAuthenticator.load( + dataDirectory.resolve("missing-secretkey.key"), Mode.COMPATIBILITY); + JsonEnvelope original = JsonEnvelope.builder(VotingPluginWire.SUB_STATUS).build(); + assertEquals(original.getFields(), compatibility.sign(original, Domain.REDIS_PROXY_BACKEND, + "backend-a", "test-channel").getFields()); + + Path invalidKey = dataDirectory.resolve("invalid-secretkey.key"); + Files.writeString(invalidKey, "not-base64"); + SharedTransportEnvelopeAuthenticator invalidCompatibility = + SharedTransportEnvelopeAuthenticator.load(invalidKey, Mode.COMPATIBILITY); + assertTrue(invalidCompatibility.verify(original, Domain.REDIS_PROXY_BACKEND, "test-channel").accepted()); + assertThrows(java.io.IOException.class, + () -> SharedTransportEnvelopeAuthenticator.load(invalidKey, Mode.REQUIRED)); + } + + @Test + void inboundPolicyEquivalenceRequiresSameKeyAndMode() { + SharedTransportEnvelopeAuthenticator required = authenticator(); + + assertTrue(required.hasEquivalentInboundPolicy( + SharedTransportEnvelopeAuthenticator.forTesting(KEY.clone(), Mode.REQUIRED, CLOCK))); + assertFalse(required.hasEquivalentInboundPolicy(SharedTransportEnvelopeAuthenticator.forTesting( + "different-key-material-32-bytes!".getBytes(java.nio.charset.StandardCharsets.US_ASCII), Mode.REQUIRED, + CLOCK))); + assertFalse(required.hasEquivalentInboundPolicy( + SharedTransportEnvelopeAuthenticator.forTesting(KEY, Mode.COMPATIBILITY, CLOCK))); + } + + private static SharedTransportEnvelopeAuthenticator authenticator() { + return SharedTransportEnvelopeAuthenticator.forTesting(KEY, Mode.REQUIRED, CLOCK); + } + + private static JsonEnvelope copyAs(JsonEnvelope source, String subchannel) { + return JsonEnvelope.builder(subchannel).schema(source.getSchema()).putAll(source.getFields()).build(); + } + + private static final class MutableClock extends Clock { + private final AtomicLong millis; + + private MutableClock(long millis) { + this.millis = new AtomicLong(millis); + } + + private void set(long value) { + millis.set(value); + } + + @Override + public ZoneId getZone() { + return ZoneOffset.UTC; + } + + @Override + public Clock withZone(ZoneId zone) { + return this; + } + + @Override + public Instant instant() { + return Instant.ofEpochMilli(millis()); + } + + @Override + public long millis() { + return millis.get(); + } + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/security/TransportEnvelopeEncryptionTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/security/TransportEnvelopeEncryptionTest.java new file mode 100644 index 000000000..3d830db29 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/security/TransportEnvelopeEncryptionTest.java @@ -0,0 +1,100 @@ +package com.bencodez.votingplugin.proxy.security; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope; +import com.bencodez.votingplugin.proxy.security.TransportEnvelopeEncryption.Domain; + +class TransportEnvelopeEncryptionTest { + private static final byte[] KEY = "0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.US_ASCII); + + @Test + void encryptsAndAuthenticatesCompleteEnvelope() { + TransportEnvelopeEncryption cipher = TransportEnvelopeEncryption.forTesting(KEY, Domain.PROXY_BACKEND, true); + JsonEnvelope original = JsonEnvelope.builder("Vote").schema(2).put("player", "Alex").put("secret", "value") + .build(); + + JsonEnvelope encrypted = cipher.encrypt(original); + TransportEnvelopeEncryption.Decryption result = cipher.decrypt(encrypted); + + assertNotEquals(original.getSubChannel(), encrypted.getSubChannel()); + assertFalse(encrypted.getFields().toString().contains("Alex")); + assertTrue(result.accepted()); + assertEquals(original.getSubChannel(), result.envelope().getSubChannel()); + assertEquals(original.getSchema(), result.envelope().getSchema()); + assertEquals(original.getFields(), result.envelope().getFields()); + } + + @Test + void rejectsTamperingWrongKeyAndWrongDomain() { + TransportEnvelopeEncryption cipher = TransportEnvelopeEncryption.forTesting(KEY, Domain.PROXY_BACKEND, true); + JsonEnvelope encrypted = cipher.encrypt(JsonEnvelope.builder("Vote").put("player", "Alex").build()); + String ciphertext = encrypted.getFields().get("ciphertext"); + char replacement = ciphertext.charAt(ciphertext.length() - 1) == 'A' ? 'B' : 'A'; + JsonEnvelope tampered = encrypted.toBuilder() + .put("ciphertext", ciphertext.substring(0, ciphertext.length() - 1) + replacement).build(); + + assertFalse(cipher.decrypt(tampered).accepted()); + assertFalse(TransportEnvelopeEncryption.forTesting("different-key-material-32-bytes!".getBytes(StandardCharsets.US_ASCII), + Domain.PROXY_BACKEND, true).decrypt(encrypted).accepted()); + assertFalse(TransportEnvelopeEncryption.forTesting(KEY, Domain.MULTI_PROXY, true).decrypt(encrypted).accepted()); + } + + @Test + void disabledModeAcceptsPlaintextAndCanReceiveEncryptedRolloutTraffic() { + JsonEnvelope plain = JsonEnvelope.builder("Status").put("server", "backend-a").build(); + TransportEnvelopeEncryption enabled = TransportEnvelopeEncryption.forTesting(KEY, Domain.PROXY_BACKEND, true); + TransportEnvelopeEncryption disabled = TransportEnvelopeEncryption.forTesting(KEY, Domain.PROXY_BACKEND, false); + + assertSame(plain, disabled.decrypt(plain).envelope()); + assertEquals(plain.getFields(), disabled.decrypt(enabled.encrypt(plain)).envelope().getFields()); + assertFalse(enabled.decrypt(plain).accepted()); + } + + @Test + void disabledModeStartsWithoutAReadableKeyAndRejectsEncryptedTraffic(@TempDir Path directory) throws Exception { + JsonEnvelope plain = JsonEnvelope.builder("Status").put("server", "backend-a").build(); + TransportEnvelopeEncryption enabled = TransportEnvelopeEncryption.forTesting(KEY, Domain.PROXY_BACKEND, true); + TransportEnvelopeEncryption disabled = TransportEnvelopeEncryption.load( + directory.resolve("missing-secretkey.key"), Domain.PROXY_BACKEND, false); + + assertSame(plain, disabled.decrypt(plain).envelope()); + assertFalse(disabled.decrypt(enabled.encrypt(plain)).accepted()); + assertFalse(disabled.enabled()); + } + + @Test + void inboundPolicyEquivalenceRequiresSameKeyDomainAndMode() { + TransportEnvelopeEncryption enabled = TransportEnvelopeEncryption.forTesting(KEY, Domain.PROXY_BACKEND, true); + + assertTrue(enabled.hasEquivalentInboundPolicy( + TransportEnvelopeEncryption.forTesting(KEY.clone(), Domain.PROXY_BACKEND, true))); + assertFalse(enabled.hasEquivalentInboundPolicy(TransportEnvelopeEncryption.forTesting( + "different-key-material-32-bytes!".getBytes(StandardCharsets.US_ASCII), Domain.PROXY_BACKEND, true))); + assertFalse(enabled.hasEquivalentInboundPolicy( + TransportEnvelopeEncryption.forTesting(KEY, Domain.MULTI_PROXY, true))); + assertFalse(enabled.hasEquivalentInboundPolicy( + TransportEnvelopeEncryption.forTesting(KEY, Domain.PROXY_BACKEND, false))); + } + + @Test + void keyFileIsCreatedOnceAndNeverReplaced(@TempDir Path directory) throws Exception { + Path keyFile = directory.resolve("secretkey.key"); + assertTrue(SharedSecretKeyFile.ensure(keyFile)); + String first = Files.readString(keyFile); + assertFalse(SharedSecretKeyFile.ensure(keyFile)); + assertEquals(first, Files.readString(keyFile)); + assertEquals(32, java.util.Base64.getDecoder().decode(first).length); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/security/TransportEnvelopeHttpCodecTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/security/TransportEnvelopeHttpCodecTest.java new file mode 100644 index 000000000..639a86d18 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/security/TransportEnvelopeHttpCodecTest.java @@ -0,0 +1,35 @@ +package com.bencodez.votingplugin.proxy.security; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.Test; + +import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope; +import com.bencodez.votingplugin.proxy.security.TransportEnvelopeEncryption.Domain; + +class TransportEnvelopeHttpCodecTest { + private static final byte[] OLD_KEY = "0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.US_ASCII); + private static final byte[] NEW_KEY = "fedcba9876543210fedcba9876543210".getBytes(StandardCharsets.US_ASCII); + + @Test + void appliesEncryptionOnlyAtTheWireBoundary() { + JsonEnvelope semantic = JsonEnvelope.builder("Vote").put("player", "Alex").build(); + TransportEnvelopeHttpCodec oldCodec = codec(OLD_KEY); + TransportEnvelopeHttpCodec newCodec = codec(NEW_KEY); + + JsonEnvelope oldWireEnvelope = oldCodec.encode(semantic); + assertThrows(IllegalArgumentException.class, () -> newCodec.decode(oldWireEnvelope)); + + JsonEnvelope retriedWithCurrentPolicy = newCodec.encode(semantic); + JsonEnvelope decoded = newCodec.decode(retriedWithCurrentPolicy); + assertEquals(semantic.getSubChannel(), decoded.getSubChannel()); + assertEquals(semantic.getFields(), decoded.getFields()); + } + + private TransportEnvelopeHttpCodec codec(byte[] key) { + return new TransportEnvelopeHttpCodec(TransportEnvelopeEncryption.forTesting(key, Domain.PROXY_BACKEND, true)); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTest.java index 9cbadfc35..9513f259f 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTest.java @@ -3,6 +3,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.doNothing; @@ -56,6 +57,8 @@ void setUp() throws Exception { votingPluginProxy.setGlobalDataHandler(globalDataHandler); votingPluginProxy.setMultiProxyHandler(multiProxyHandler); votingPluginProxy.setDataFolder(temporaryDirectory.toFile()); + com.bencodez.votingplugin.proxy.security.SharedSecretKeyFile + .ensure(temporaryDirectory.resolve("secretkey.key")); Mockito.when(multiProxyHandler.sendMultiProxyEnvelopeAccepted(Mockito.any())).thenReturn(true); Mockito.when(multiProxyHandler.sendMultiProxyEnvelopeAccepted(Mockito.any(), Mockito.any())).thenReturn(true); Mockito.when(multiProxyHandler.getMultiProxyVoteRecipients()).thenReturn(java.util.Set.of("Replica")); @@ -133,6 +136,112 @@ void httpControlEnrollmentUsesAuthenticatedBackendIdentity() { assertEquals("Server1", proxy.getControlEnrollmentSource()); } + @Test + void decodedHttpEnvelopeKeepsAuthenticatedTlsSourceForRouting() throws Exception { + votingPluginProxy.setMethod(BungeeMethod.HTTP); + var handler = Mockito.mock(com.bencodez.simpleapi.servercomm.global.GlobalMessageProxyHandler.class); + votingPluginProxy.setGlobalMessageProxyHandlerForTest(handler); + JsonEnvelope status = VotingPluginWire.status("Server1"); + + votingPluginProxy.handleHttpTransportEnvelopeForTest(new HttpProxyTransportServer.ReceivedEnvelope( + "Server1", "message-1", status)); + votingPluginProxy.handleHttpTransportEnvelopeForTest(new HttpProxyTransportServer.ReceivedEnvelope( + "Server2", "message-2", status)); + + org.mockito.ArgumentCaptor delivered = org.mockito.ArgumentCaptor.forClass(JsonEnvelope.class); + verify(handler).onMessage(delivered.capture()); + assertEquals(status.getSubChannel(), delivered.getValue().getSubChannel()); + assertEquals(status.getFields(), delivered.getValue().getFields()); + Mockito.verifyNoMoreInteractions(handler); + + JsonEnvelope enrollment = VotingPluginWire.controlEnrollmentRequest("Server1", "", + "http://control.example.test:2150", java.util.UUID.randomUUID()); + votingPluginProxy.handleHttpTransportEnvelopeForTest(new HttpProxyTransportServer.ReceivedEnvelope( + "Server1", "message-3", enrollment)); + assertEquals("Server1", votingPluginProxy.getControlEnrollmentSource()); + } + + @Test + void durableHttpQueueReceivesSemanticEnvelopeBeforeWireEncoding() throws Exception { + HttpProxyTransportServer transport = Mockito.mock(HttpProxyTransportServer.class); + setProxyField(votingPluginProxy, "httpTransportServer", transport); + JsonEnvelope semantic = VotingPluginWire.status("Server1"); + Mockito.when(transport.send(Mockito.eq("Server1"), Mockito.eq("delivery-1"), + Mockito.any(JsonEnvelope.class))).thenReturn(true); + + assertTrue(votingPluginProxy.sendDurableHttpEnvelopeThroughTransportForTest( + "Server1", "delivery-1", semantic)); + + org.mockito.ArgumentCaptor persisted = org.mockito.ArgumentCaptor.forClass(JsonEnvelope.class); + verify(transport).send(Mockito.eq("Server1"), Mockito.eq("delivery-1"), persisted.capture()); + assertEquals(semantic.getSubChannel(), persisted.getValue().getSubChannel()); + assertEquals(semantic.getFields(), persisted.getValue().getFields()); + } + + @Test + void multiProxyHandlerKeepsItsAuthenticatorGenerationDuringReload() throws Exception { + var oldAuthenticator = com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator.load( + temporaryDirectory.resolve("secretkey.key"), + com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator.Mode.COMPATIBILITY); + setProxyField(votingPluginProxy, "sharedTransportAuthenticator", oldAuthenticator); + votingPluginProxy.loadMultiProxySupport(); + MultiProxyHandler installed = votingPluginProxy.getMultiProxyHandler(); + + byte[] replacementKey = new byte[32]; + java.util.Arrays.fill(replacementKey, (byte) 1); + java.nio.file.Files.writeString(temporaryDirectory.resolve("secretkey.key"), + java.util.Base64.getEncoder().encodeToString(replacementKey)); + var replacementAuthenticator = com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator.load( + temporaryDirectory.resolve("secretkey.key"), + com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator.Mode.REQUIRED); + setProxyField(votingPluginProxy, "sharedTransportAuthenticator", replacementAuthenticator); + + assertSame(oldAuthenticator, installed.getSharedTransportAuthenticator()); + assertSame(replacementAuthenticator, getProxyField(votingPluginProxy, "sharedTransportAuthenticator")); + } + + @Test + void encryptedPluginEnrollmentUsesConnectionSourceValidation() throws Exception { + votingPluginProxy.setMethod(BungeeMethod.PLUGINMESSAGING); + votingPluginProxy.setValidateControlEnrollmentRequest(true); + java.nio.file.Files.writeString(temporaryDirectory.resolve("secretkey.key"), + java.util.Base64.getEncoder().encodeToString(new byte[32])); + JsonEnvelope enrollment = communicationEncryption().encrypt(VotingPluginWire.controlEnrollmentRequest( + "Server1", "a".repeat(64), "http://control.example.test:2150", java.util.UUID.randomUUID())); + byte[] packet = pluginMessagePacket(enrollment); + + votingPluginProxy.onPluginMessageReceived(new java.io.DataInputStream( + new java.io.ByteArrayInputStream(packet)), "Server2"); + assertEquals(0, votingPluginProxy.getControlEnrollmentInstallCount()); + votingPluginProxy.onPluginMessageReceived(new java.io.DataInputStream( + new java.io.ByteArrayInputStream(packet)), "Server1"); + assertEquals(1, votingPluginProxy.getControlEnrollmentInstallCount()); + assertEquals("Server1", votingPluginProxy.getControlEnrollmentSource()); + } + + private com.bencodez.votingplugin.proxy.security.TransportEnvelopeEncryption communicationEncryption() + throws Exception { + java.nio.file.Path keyFile = temporaryDirectory.resolve("secretkey.key"); + java.nio.file.Files.writeString(keyFile, java.util.Base64.getEncoder().encodeToString(new byte[32])); + var encryption = com.bencodez.votingplugin.proxy.security.TransportEnvelopeEncryption.load(keyFile, + com.bencodez.votingplugin.proxy.security.TransportEnvelopeEncryption.Domain.PROXY_BACKEND, true); + var field = VotingPluginProxy.class.getDeclaredField("communicationEncryption"); + field.setAccessible(true); + field.set(votingPluginProxy, encryption); + return encryption; + } + + private static byte[] pluginMessagePacket(JsonEnvelope envelope) throws Exception { + String payload = com.bencodez.simpleapi.servercomm.codec.JsonEnvelopeCodec.encode(envelope); + var bytes = new java.io.ByteArrayOutputStream(); + try (var output = new java.io.DataOutputStream(bytes)) { + output.writeUTF(envelope.getSubChannel()); + output.writeInt(payload.getBytes(java.nio.charset.StandardCharsets.UTF_8).length); + output.writeUTF(payload); + } + return bytes.toByteArray(); + } + @Test void backendControlEnrollmentRejectsMismatchedTransportIdentity() { VotingPluginProxyTestImpl proxy = new VotingPluginProxyTestImpl(); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTestImpl.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTestImpl.java index f41720f53..ad0929e3e 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTestImpl.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTestImpl.java @@ -63,6 +63,7 @@ public class VotingPluginProxyTestImpl extends VotingPluginProxy { private String controlEnrollmentSource; private int controlEnrollmentInstallCount; private JsonEnvelope controlEnrollmentResult; + private boolean validateControlEnrollmentRequest; private boolean controlEnrollmentRouteProved = true; public List getWarnings() { @@ -186,6 +187,10 @@ public int getControlEnrollmentInstallCount() { return controlEnrollmentInstallCount; } + public void setValidateControlEnrollmentRequest(boolean validate) { + validateControlEnrollmentRequest = validate; + } + public void setControlEnrollmentRouteProved(boolean proved) { controlEnrollmentRouteProved = proved; } @@ -196,7 +201,8 @@ public void handleAuthenticatedHttpEnvelopeForTest(HttpProxyTransportServer.Rece @Override protected void handleControlEnrollmentRequest(String sourceServer, JsonEnvelope envelope) { - controlEnrollmentSource = sourceServer; + if (validateControlEnrollmentRequest) super.handleControlEnrollmentRequest(sourceServer, envelope); + else controlEnrollmentSource = sourceServer; } @Override @@ -541,6 +547,11 @@ public boolean sendStableHttpEnvelopeForTest(String server, String deliveryId, J return sendStableHttpEnvelope(server, deliveryId, envelope); } + public boolean sendDurableHttpEnvelopeThroughTransportForTest(String server, String deliveryId, + JsonEnvelope envelope) { + return super.sendHttpEnvelope(server, deliveryId, envelope); + } + public boolean sendHttpVoteEnvelopeWithRecoveryForTest(String server, JsonEnvelope envelope, OfflineBungeeVote cachedVote) { return sendHttpEnvelopeWithRecovery(server, envelope, cachedVote); diff --git a/docs/shared-transport-authentication.md b/docs/shared-transport-authentication.md new file mode 100644 index 000000000..deb801d7f --- /dev/null +++ b/docs/shared-transport-authentication.md @@ -0,0 +1,46 @@ +# Shared transport authentication + +VotingPlugin can authenticate every ordinary envelope carried over Redis, MQTT, or multi-proxy Redis before routing +it. Authentication is enforced in `REQUIRED` mode. The authentication key is derived from the existing +`secretkey.key`; every proxy and backend in one network must use the same copied file. Networks sharing a broker should +use different key files and different `Redis.Prefix` values. + +`SharedTransportAuthentication` has two modes: + +- `COMPATIBILITY` is the upgrade-safe default. It keeps outbound messages unsigned and temporarily accepts wholly + unsigned legacy messages. For multi-proxy Redis with a nonempty prefix, it also publishes and subscribes on the + legacy unprefixed channel so upgraded and legacy peers can communicate. Both subscriptions pass through the same authentication + verifier. It logs a warning because an untrusted broker publisher can forge unsigned legacy messages and the legacy + channel does not isolate networks by prefix. +- `REQUIRED` rejects missing, invalid, stale, or replayed authentication before message handling. + +For a rolling upgrade, leave the default `COMPATIBILITY` mode active while upgrading all nodes. This deliberately +keeps traffic unsigned so independently generated node keys cannot interrupt an existing network. Distribute the same +`secretkey.key`, confirm communication, then change every node to `REQUIRED` and reload or restart. Do not leave a network in +`COMPATIBILITY` mode after the rollout. + +The MAC covers a distinct Redis, MQTT, or multi-proxy Redis protocol domain, schema, sender identity, subchannel, +complete payload, timestamp, and a random message ID. Exact authenticated replays are rejected by a bounded in-memory +cache sized for sustained broker traffic across the complete freshness window. Vote retries retain their existing +stable vote ID and receive fresh transport authentication for each publish. + +`CommunicationEncryption` is a separate, optional confidentiality layer for complete proxy/backend and multi-proxy +envelopes across every configured communication method. It uses AES-256-GCM and keys derived for separate +proxy/backend and multi-proxy domains. HTTP retains mutually authenticated TLS as its outer transport; Redis and MQTT +retain the mandatory MAC above around the encrypted envelope. `PluginMessageEncryption` remains only as a deprecated +plugin-message framing compatibility setting. + +Every node creates `secretkey.key` at startup even while `CommunicationEncryption` is disabled. Copy the proxy's file +to every backend and other proxy, then enable `CommunicationEncryption` everywhere and restart. An enabled receiver +rejects plaintext envelopes. A disabled upgraded receiver can decrypt encrypted envelopes, which permits verification +before the coordinated enable/restart step. Startup logs recommend this setup without printing key material. + +Multi-proxy Redis channels use the ordinary `Redis.Prefix` namespace: + +```text +VotingPluginProxy_ +``` + +The publisher and subscriber derive that name through the same channel helper, including when multi-proxy support +reuses the ordinary Redis connection. `REQUIRED` mode uses only this prefixed channel. The temporary legacy channel +bridge described above is active only in `COMPATIBILITY` mode.