From 6402e23786103a5fccecebf6655a693eb0b53507 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Tue, 22 Sep 2026 18:58:28 -0600 Subject: [PATCH 1/3] Secure backend Control auto-enrollment across transports --- .../votingplugin/VotingPluginMain.java | 32 +++++ .../control/BackendControlAutoEnrollment.java | 36 +++-- .../votingplugin/proxy/VotingPluginProxy.java | 112 ++++++++++++++-- .../votingplugin/proxy/VotingPluginWire.java | 32 ++++- VotingPlugin/src/main/resources/Config.yml | 2 +- .../BackendControlAutoEnrollmentTest.java | 124 ++++++++++++++++++ .../tests/VotingPluginProxyTest.java | 93 +++++++++++++ .../tests/VotingPluginProxyTestImpl.java | 60 ++++++++- .../tests/VotingPluginWireTest.java | 13 +- docs/control-connector.md | 18 +-- 10 files changed, 482 insertions(+), 40 deletions(-) create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlAutoEnrollmentTest.java diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java index 05f3eb66fb..b902604864 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java @@ -1121,6 +1121,38 @@ public synchronized void restartBackendControlConnector() { } } + /** Starts the connector for an admitted enrollment without replacing its route challenge. */ + public void startBackendControlConnectorForEnrollment(BackendControlAutoEnrollment enrollment) { + try { + backendControlConnectorLifecycle.execute(() -> { + BackendControlConnector replacement; + synchronized (this) { + if (backendControlConnectorStopping || backendControlAutoEnrollment != enrollment + || backendControlConnector != null && !backendControlConnector.isClosed()) return; + } + try { + replacement = BackendControlConnector.create(this); + } catch (Exception e) { + getLogger().warning("[Control] Bukkit connector could not start after automatic enrollment: " + + e.getMessage()); + return; + } + if (replacement == null) return; + synchronized (this) { + if (backendControlConnectorStopping || backendControlAutoEnrollment != enrollment + || backendControlConnector != null && !backendControlConnector.isClosed()) { + replacement.close(); + return; + } + backendControlConnector = replacement; + replacement.start(); + } + }); + } catch (RejectedExecutionException e) { + getLogger().warning("[Control] Bukkit connector could not start after lifecycle shutdown"); + } + } + private void reconcileBackendControlConnector() { while (true) { BackendControlConnector previous; diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlAutoEnrollment.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlAutoEnrollment.java index 34a02dca98..88ae117454 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlAutoEnrollment.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlAutoEnrollment.java @@ -11,7 +11,6 @@ import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope; import com.bencodez.votingplugin.VotingPluginMain; import com.bencodez.votingplugin.backendproxy.BackendProxyHandler; -import com.bencodez.votingplugin.proxy.BungeeMethod; import com.bencodez.votingplugin.proxy.VotingPluginWire; import com.bencodez.votingplugin.proxy.control.HostedControlManager; import com.bencodez.votingplugin.proxy.control.HostedControlManager.HostConfiguration; @@ -38,6 +37,7 @@ public final class BackendControlAutoEnrollment implements AutoCloseable { private boolean verifierInstalled; private boolean connectorAuthenticated; private long verifierAcknowledgedAt; + private String routeChallenge = ""; private BackendControlAutoEnrollment(VotingPluginMain plugin, String nodeId, String credentialFile, String endpoint, PendingAutoEnrollment enrollment) { @@ -59,14 +59,13 @@ public static PendingAutoEnrollment prepareLocal(VotingPluginMain plugin, HostCo return prepare(plugin, control); } - /** Prepares proxy-mediated enrollment only when source-bound plugin messaging is active. */ + /** Prepares proxy-mediated enrollment through the configured backend transport. */ public static BackendControlAutoEnrollment create(VotingPluginMain plugin, HostConfiguration hosted) throws IOException { ConfigurationSection control = control(plugin); if (control == null || !control.getBoolean("Enabled", false) || HostedControlManager.isDirectLocalEndpoint(control.getString("Endpoint", ""), hosted) - || !plugin.getBungeeSettings().isUseBungeecoord() - || BungeeMethod.getByName(plugin.getBungeeSettings().getBungeeMethod()) != BungeeMethod.PLUGINMESSAGING) { + || !plugin.getBungeeSettings().isUseBungeecoord()) { return null; } String serverName = plugin.getOptions().getServer(); @@ -132,23 +131,36 @@ private void send() { pending = enrollment; } BackendProxyHandler handler = plugin.getBackendProxyHandler(); - if (handler == null || handler.getMethod() != BungeeMethod.PLUGINMESSAGING - || handler.getGlobalMessageHandler() == null) return; + if (handler == null || handler.getGlobalMessageHandler() == null) return; handler.getGlobalMessageHandler().sendMessage(VotingPluginWire.controlEnrollmentRequest( - nodeId, pending == null ? "" : pending.verifier(), endpoint, requestId)); + nodeId, pending == null ? "" : pending.verifier(), endpoint, requestId, routeChallenge)); } public void handle(JsonEnvelope envelope) { VotingPluginWire.ControlEnrollmentResult result = VotingPluginWire.readControlEnrollmentResult(envelope); - boolean restartConnector = false; + boolean ensureConnector = false; synchronized (this) { - if (closed.get() || !result.valid || !result.success || !requestId.equals(result.requestId) + if (closed.get() || !result.valid || !requestId.equals(result.requestId) || !nodeId.equals(result.nodeId)) return; - if (enrollment == null) { + if (!result.challenge.isEmpty()) { + routeChallenge = result.challenge; + if (enrollment == null) { + try { + enrollment = prepare(plugin, credentialFile, nodeId); + if (enrollment == null) close(); + } catch (IOException e) { + plugin.getLogger().warning( + "[Control] Automatic backend credential could not be prepared; it will retry"); + } + } + ensureConnector = enrollment != null; + } else if (!result.success) { + return; + } else if (enrollment == null) { try { enrollment = prepare(plugin, credentialFile, nodeId); if (enrollment == null) close(); - restartConnector = true; + ensureConnector = enrollment != null; } catch (IOException e) { plugin.getLogger().warning( "[Control] Automatic backend credential could not be prepared; it will retry"); @@ -159,7 +171,7 @@ public void handle(JsonEnvelope envelope) { completeIfConnected(); } } - if (restartConnector) plugin.restartBackendControlConnector(); + if (ensureConnector) plugin.startBackendControlConnectorForEnrollment(this); } /** Completes durable enrollment only after this credential authenticated to the configured endpoint. */ 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 6c4f46aafd..dbfe10c8c2 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java @@ -197,6 +197,7 @@ public void run() { private static final long PRESENCE_MAINTENANCE_INTERVAL_SECONDS = 30L; private static final long PRESENCE_BACKEND_TIMEOUT_MILLIS = TimeUnit.SECONDS.toMillis(90); private static final long CONTROL_ENROLLMENT_MIN_INTERVAL_NANOS = TimeUnit.SECONDS.toNanos(10); + private static final long CONTROL_ENROLLMENT_CHALLENGE_TTL_NANOS = TimeUnit.MINUTES.toNanos(1); private static final int MAX_PENDING_VOTE_PARTY_REWARDS = 1024; private static final long HTTP_TRANSPORT_RECONCILIATION_DELAY_MILLIS = 100L; // Acks run before SimpleAPI removes an entry. Keep one bounded, single-flight @@ -333,6 +334,7 @@ private void close() { private final Map pendingPresenceHandoffs = new HashMap<>(); private final Set pendingBackendRecoverySnapshots = ConcurrentHashMap.newKeySet(); private final Map controlEnrollmentNextAllowed = new ConcurrentHashMap<>(); + private final Map controlEnrollmentChallenges = new ConcurrentHashMap<>(); private final Map pendingCommunicationTests = new ConcurrentHashMap<>(); private volatile ControlConnector controlConnector; private volatile HostedControlManager hostedControlManager; @@ -1820,6 +1822,7 @@ public void sendMessage(String server, int delay, JsonEnvelope envelope) { } } }; + registerControlEnrollmentListener(globalMessageProxyHandler); globalMessageProxyHandler.addListener(new GlobalMessageListener(VotingPluginWire.SUB_LOGIN) { @Override @@ -3301,14 +3304,59 @@ public void onPluginMessageReceived(DataInputStream in, String sourceServer) { }); } - private void handleControlEnrollmentRequest(String sourceServer, JsonEnvelope envelope) { + protected void registerControlEnrollmentListener(GlobalMessageProxyHandler handler) { + handler.addListener(new GlobalMessageListener(VotingPluginWire.SUB_CONTROL_ENROLLMENT_REQUEST) { + @Override + public void onReceive(JsonEnvelope message) { + if (method == BungeeMethod.PLUGINMESSAGING || method == BungeeMethod.HTTP) return; + VotingPluginWire.ControlEnrollmentRequest request = + VotingPluginWire.readControlEnrollmentRequest(message); + String sourceServer = message.getFields().getOrDefault(VotingPluginWire.K_SERVER, ""); + if (!request.valid || !request.nodeId.equals(sourceServer) + || !isPresenceServerValid(sourceServer, + VotingPluginWire.SUB_CONTROL_ENROLLMENT_REQUEST)) return; + handleUnboundControlEnrollmentRequest(sourceServer, message, request); + } + }); + } + + private void handleUnboundControlEnrollmentRequest(String sourceServer, JsonEnvelope envelope, + VotingPluginWire.ControlEnrollmentRequest request) { + if (request.verifier.isEmpty()) { + if (!allowControlEnrollmentAttempt(sourceServer)) return; + requestControlEnrollmentChallenge(sourceServer, request); + return; + } + AtomicBoolean accepted = new AtomicBoolean(); + long now = System.nanoTime(); + controlEnrollmentChallenges.compute(sourceServer, (ignored, pending) -> { + if (pending == null || pending.expiresAtNanos <= now) return null; + if (pending.requestId.equals(request.requestId) && pending.endpoint.equals(request.endpoint) + && pending.challenge.equals(request.challenge)) { + accepted.set(true); + return null; + } + return pending; + }); + if (!accepted.get()) { + if (allowControlEnrollmentAttempt(sourceServer)) requestControlEnrollmentChallenge(sourceServer, request); + return; + } + installControlEnrollmentRequest(sourceServer, request); + } + + protected void handleControlEnrollmentRequest(String sourceServer, JsonEnvelope envelope) { VotingPluginWire.ControlEnrollmentRequest request = VotingPluginWire.readControlEnrollmentRequest(envelope); if (!request.valid || sourceServer == null || sourceServer.isBlank()) return; if (!sourceServer.equals(request.nodeId)) { - sendPluginMessageServer(sourceServer, 0, - VotingPluginWire.controlEnrollmentResult(sourceServer, request.requestId, false)); + sendControlEnrollmentResult(sourceServer, request.requestId, false); return; } + if (!allowControlEnrollmentAttempt(sourceServer)) return; + installControlEnrollmentRequest(sourceServer, request); + } + + private boolean allowControlEnrollmentAttempt(String sourceServer) { long now = System.nanoTime(); AtomicBoolean allowed = new AtomicBoolean(); controlEnrollmentNextAllowed.compute(sourceServer, (ignored, nextAllowed) -> { @@ -3318,21 +3366,62 @@ private void handleControlEnrollmentRequest(String sourceServer, JsonEnvelope en } return nextAllowed; }); - if (!allowed.get()) return; + return allowed.get(); + } + + private void requestControlEnrollmentChallenge(String sourceServer, + VotingPluginWire.ControlEnrollmentRequest request) { + proveControlEnrollmentRoute(sourceServer, request.endpoint).whenComplete((proved, failure) -> { + if (failure != null || !Boolean.TRUE.equals(proved)) { + sendControlEnrollmentResult(sourceServer, request.requestId, false); + return; + } + sendControlEnrollmentChallenge(sourceServer, request); + }); + } + + protected CompletableFuture proveControlEnrollmentRoute(String sourceServer, String endpoint) { + HostedControlManager manager = hostedControlManager; + return manager == null ? CompletableFuture.completedFuture(false) + : manager.installNodeVerifier(sourceServer, "", endpoint); + } + + private void sendControlEnrollmentChallenge(String sourceServer, + VotingPluginWire.ControlEnrollmentRequest request) { + long now = System.nanoTime(); + ControlEnrollmentChallenge challenge = controlEnrollmentChallenges.compute(sourceServer, (ignored, current) -> { + if (current != null && current.expiresAtNanos > now && current.requestId.equals(request.requestId) + && current.endpoint.equals(request.endpoint)) return current; + return new ControlEnrollmentChallenge(request.requestId, request.endpoint, UUID.randomUUID().toString(), + now + CONTROL_ENROLLMENT_CHALLENGE_TTL_NANOS); + }); + sendControlEnrollmentResult(sourceServer, request.requestId, false, challenge.challenge); + } + + protected void installControlEnrollmentRequest(String sourceServer, + VotingPluginWire.ControlEnrollmentRequest request) { HostedControlManager manager = hostedControlManager; if (manager == null) { - sendPluginMessageServer(sourceServer, 0, - VotingPluginWire.controlEnrollmentResult(sourceServer, request.requestId, false)); + sendControlEnrollmentResult(sourceServer, request.requestId, false); return; } manager.installNodeVerifier(sourceServer, request.verifier, request.endpoint).whenComplete((installed, failure) -> { boolean success = failure == null && Boolean.TRUE.equals(installed); - sendPluginMessageServer(sourceServer, 0, - VotingPluginWire.controlEnrollmentResult(sourceServer, request.requestId, success)); + sendControlEnrollmentResult(sourceServer, request.requestId, success); if (success) log("[Control] automatically enrolled backend node " + sourceServer); }); } + protected void sendControlEnrollmentResult(String server, UUID requestId, boolean success) { + sendControlEnrollmentResult(server, requestId, success, ""); + } + + protected void sendControlEnrollmentResult(String server, UUID requestId, boolean success, String challenge) { + GlobalMessageProxyHandler handler = globalMessageProxyHandler; + if (handler != null) handler.sendMessage(server, 0, + VotingPluginWire.controlEnrollmentResult(server, requestId, success, challenge)); + } + private UUID parseUUIDFromString(String uuidAsString) { String[] parts = { "0x" + uuidAsString.substring(0, 8), "0x" + uuidAsString.substring(8, 12), "0x" + uuidAsString.substring(12, 16), "0x" + uuidAsString.substring(16, 20), @@ -3908,6 +3997,10 @@ protected void handleHttpTransportEnvelope(HttpProxyTransportServer.ReceivedEnve 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()); + return; + } GlobalMessageProxyHandler handler = globalMessageProxyHandler; if (handler == null) throw new IllegalStateException("HTTP message router is not ready"); handler.onMessage(received.envelope()); @@ -6117,6 +6210,9 @@ private static UUID parsePlayerUuid(String uuid) { } } + private record ControlEnrollmentChallenge(UUID requestId, String endpoint, String challenge, + long expiresAtNanos) { } + public abstract void warn(String message); public abstract ScheduledExecutorService getScheduler(); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginWire.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginWire.java index 598edac8a8..55eab09c51 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginWire.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginWire.java @@ -96,6 +96,7 @@ private VotingPluginWire() { public static final String K_NODE_ID = "nodeId"; public static final String K_VERIFIER = "verifier"; public static final String K_ENDPOINT = "endpoint"; + public static final String K_CHALLENGE = "challenge"; public static final String K_SUCCESS = "success"; // Vote/VoteOnline extras @@ -225,15 +226,27 @@ public static JsonEnvelope serverName(String server) { } public static JsonEnvelope controlEnrollmentRequest(String nodeId, String verifier, String endpoint, UUID requestId) { - return base(SUB_CONTROL_ENROLLMENT_REQUEST).put(K_NODE_ID, safe(nodeId)) + return controlEnrollmentRequest(nodeId, verifier, endpoint, requestId, ""); + } + + public static JsonEnvelope controlEnrollmentRequest(String nodeId, String verifier, String endpoint, UUID requestId, + String challenge) { + return base(SUB_CONTROL_ENROLLMENT_REQUEST).put(K_NODE_ID, safe(nodeId)).put(K_SERVER, safe(nodeId)) .put(K_VERIFIER, safe(verifier)) .put(K_ENDPOINT, safe(endpoint)) + .put(K_CHALLENGE, safe(challenge)) .put(K_REQUEST_ID, requestId == null ? "" : requestId.toString()).build(); } public static JsonEnvelope controlEnrollmentResult(String nodeId, UUID requestId, boolean success) { + return controlEnrollmentResult(nodeId, requestId, success, ""); + } + + public static JsonEnvelope controlEnrollmentResult(String nodeId, UUID requestId, boolean success, + String challenge) { return base(SUB_CONTROL_ENROLLMENT_RESULT).put(K_NODE_ID, safe(nodeId)) .put(K_REQUEST_ID, requestId == null ? "" : requestId.toString()) + .put(K_CHALLENGE, safe(challenge)) .put(K_SUCCESS, success).build(); } @@ -560,16 +573,20 @@ public static final class ControlEnrollmentRequest { public final String nodeId; public final String verifier; public final String endpoint; + public final String challenge; public final UUID requestId; public final boolean valid; - private ControlEnrollmentRequest(String nodeId, String verifier, String endpoint, UUID requestId) { + private ControlEnrollmentRequest(String nodeId, String verifier, String endpoint, UUID requestId, + String challenge) { this.nodeId = nodeId; this.verifier = verifier; this.endpoint = endpoint; + this.challenge = challenge; this.requestId = requestId; this.valid = nodeId.matches("[A-Za-z0-9][A-Za-z0-9._-]{0,63}") && (verifier.isEmpty() || verifier.matches("[0-9a-f]{64}")) + && (challenge.isEmpty() || challenge.matches("[0-9a-f-]{36}")) && !endpoint.isBlank() && endpoint.length() <= 2048 && requestId != null; } @@ -578,27 +595,30 @@ private ControlEnrollmentRequest(String nodeId, String verifier, String endpoint public static ControlEnrollmentRequest readControlEnrollmentRequest(JsonEnvelope env) { Map fields = env.getFields(); return new ControlEnrollmentRequest(safe(fields.get(K_NODE_ID)), safe(fields.get(K_VERIFIER)), - safe(fields.get(K_ENDPOINT)), readUuid(fields, K_REQUEST_ID)); + safe(fields.get(K_ENDPOINT)), readUuid(fields, K_REQUEST_ID), safe(fields.get(K_CHALLENGE))); } public static final class ControlEnrollmentResult { public final String nodeId; public final UUID requestId; public final boolean success; + public final String challenge; public final boolean valid; - private ControlEnrollmentResult(String nodeId, UUID requestId, boolean success) { + private ControlEnrollmentResult(String nodeId, UUID requestId, boolean success, String challenge) { this.nodeId = nodeId; this.requestId = requestId; this.success = success; - this.valid = nodeId.matches("[A-Za-z0-9][A-Za-z0-9._-]{0,63}") && requestId != null; + this.challenge = challenge; + this.valid = nodeId.matches("[A-Za-z0-9][A-Za-z0-9._-]{0,63}") && requestId != null + && (challenge.isEmpty() || challenge.matches("[0-9a-f-]{36}")); } } public static ControlEnrollmentResult readControlEnrollmentResult(JsonEnvelope env) { Map fields = env.getFields(); return new ControlEnrollmentResult(safe(fields.get(K_NODE_ID)), readUuid(fields, K_REQUEST_ID), - readBool(fields, K_SUCCESS, false)); + readBool(fields, K_SUCCESS, false), safe(fields.get(K_CHALLENGE))); } public static final class PresencePlayer { diff --git a/VotingPlugin/src/main/resources/Config.yml b/VotingPlugin/src/main/resources/Config.yml index b0eec3d20d..db51f4552d 100644 --- a/VotingPlugin/src/main/resources/Config.yml +++ b/VotingPlugin/src/main/resources/Config.yml @@ -1160,7 +1160,7 @@ Webhooks: ########################################### # VotingPlugin Control backend connector # Optional outbound connection for full local WebUI configuration control. -# A locally hosted Control, or a proxy-hosted Control reached with PLUGINMESSAGING, +# A locally hosted Control, or a proxy-hosted Control reached through the configured proxy transport, # generates and enrolls credentials automatically. The credential never enters this YAML. ########################################### diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlAutoEnrollmentTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlAutoEnrollmentTest.java new file mode 100644 index 0000000000..6d696d3c03 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlAutoEnrollmentTest.java @@ -0,0 +1,124 @@ +package com.bencodez.votingplugin.control; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.when; + +import java.nio.file.Path; +import java.nio.file.Files; +import java.util.stream.Stream; + +import org.bukkit.Server; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.scheduler.BukkitScheduler; +import org.bukkit.scheduler.BukkitTask; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.TestFactory; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.ArgumentCaptor; + +import com.bencodez.advancedcore.AdvancedCoreConfigOptions; +import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope; +import com.bencodez.simpleapi.servercomm.global.GlobalMessageHandler; +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.backendproxy.BackendProxyHandler; +import com.bencodez.votingplugin.config.BungeeSettings; +import com.bencodez.votingplugin.config.Config; +import com.bencodez.votingplugin.proxy.BungeeMethod; +import com.bencodez.votingplugin.proxy.VotingPluginWire; +import com.bencodez.votingplugin.proxy.control.HostedControlManager.HostConfiguration; + +class BackendControlAutoEnrollmentTest { + @TempDir + Path directory; + + @TestFactory + Stream submitsEnrollmentThroughEveryBackendTransport() { + return Stream.of(BungeeMethod.values()).map(method -> DynamicTest.dynamicTest(method.name(), + () -> submitEnrollment(method))); + } + + private void submitEnrollment(BungeeMethod method) throws Exception { + Path methodDirectory = directory.resolve(method.name()); + Files.createDirectories(methodDirectory); + VotingPluginMain plugin = mock(VotingPluginMain.class); + Config config = mock(Config.class); + BungeeSettings bungee = mock(BungeeSettings.class); + AdvancedCoreConfigOptions options = mock(AdvancedCoreConfigOptions.class); + Server server = mock(Server.class); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + BukkitTask task = mock(BukkitTask.class); + BackendProxyHandler handler = mock(BackendProxyHandler.class); + GlobalMessageHandler messages = mock(GlobalMessageHandler.class); + YamlConfiguration data = new YamlConfiguration(); + data.set("Control.Backend.Enabled", true); + data.set("Control.Backend.Endpoint", "http://control.example.test:2150"); + data.set("Control.Backend.NodeId", "backend-a"); + data.set("Control.Backend.CredentialFile", "control/control-credential.txt"); + when(plugin.getConfigFile()).thenReturn(config); + when(config.getData()).thenReturn(data); + when(plugin.getBungeeSettings()).thenReturn(bungee); + when(bungee.isUseBungeecoord()).thenReturn(true); + when(bungee.getBungeeMethod()).thenReturn(method.name()); + when(plugin.getOptions()).thenReturn(options); + when(options.getServer()).thenReturn("backend-a"); + when(plugin.getDataFolder()).thenReturn(methodDirectory.toFile()); + when(plugin.getServer()).thenReturn(server); + when(server.getScheduler()).thenReturn(scheduler); + ArgumentCaptor scheduled = ArgumentCaptor.forClass(Runnable.class); + when(scheduler.runTaskTimer(eq(plugin), scheduled.capture(), anyLong(), anyLong())).thenReturn(task); + when(plugin.getBackendProxyHandler()).thenReturn(handler); + when(handler.getGlobalMessageHandler()).thenReturn(messages); + + HostConfiguration hosted = new HostConfiguration(false, false, false, "", "", "", "", + "127.0.0.1", 0, 1, 1); + try (BackendControlAutoEnrollment enrollment = BackendControlAutoEnrollment.create(plugin, hosted)) { + assertNotNull(enrollment); + assertTrue(enrollment.isAwaitingCredential()); + enrollment.start(); + scheduled.getValue().run(); + + ArgumentCaptor initialRequest = ArgumentCaptor.forClass(JsonEnvelope.class); + verify(messages).sendMessage(initialRequest.capture()); + JsonEnvelope initial = initialRequest.getValue(); + assertEquals(VotingPluginWire.SUB_CONTROL_ENROLLMENT_REQUEST, initial.getSubChannel()); + assertEquals("backend-a", initial.getFields().get(VotingPluginWire.K_SERVER)); + java.util.UUID requestId = java.util.UUID.fromString( + initial.getFields().get(VotingPluginWire.K_REQUEST_ID)); + String challenge = java.util.UUID.randomUUID().toString(); + enrollment.handle(VotingPluginWire.controlEnrollmentResult("backend-a", requestId, false, challenge)); + verify(plugin).startBackendControlConnectorForEnrollment(enrollment); + org.mockito.Mockito.clearInvocations(messages); + scheduled.getValue().run(); + + ArgumentCaptor provedRequest = ArgumentCaptor.forClass(JsonEnvelope.class); + verify(messages).sendMessage(provedRequest.capture()); + JsonEnvelope proved = provedRequest.getValue(); + VotingPluginWire.ControlEnrollmentRequest parsed = + VotingPluginWire.readControlEnrollmentRequest(proved); + assertTrue(parsed.valid); + assertEquals(challenge, parsed.challenge); + assertEquals(64, parsed.verifier.length()); + + // A repeated challenge retries connector startup without replacing this enrollment, + // its request correlation, or the verifier that was already written. + enrollment.handle(VotingPluginWire.controlEnrollmentResult("backend-a", requestId, false, challenge)); + verify(plugin, times(2)).startBackendControlConnectorForEnrollment(enrollment); + org.mockito.Mockito.clearInvocations(messages); + scheduled.getValue().run(); + ArgumentCaptor retriedRequest = ArgumentCaptor.forClass(JsonEnvelope.class); + verify(messages).sendMessage(retriedRequest.capture()); + VotingPluginWire.ControlEnrollmentRequest retried = + VotingPluginWire.readControlEnrollmentRequest(retriedRequest.getValue()); + assertEquals(requestId, retried.requestId); + assertEquals(challenge, retried.challenge); + assertEquals(parsed.verifier, retried.verifier); + } + } +} 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 3b77956d2f..fe99a514e6 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTest.java @@ -10,7 +10,9 @@ import static org.mockito.Mockito.verify; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DynamicTest; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestFactory; import org.junit.jupiter.api.io.TempDir; import org.mockito.InjectMocks; import org.mockito.Mock; @@ -60,6 +62,97 @@ void setUp() { } + @TestFactory + java.util.stream.Stream backendControlEnrollmentRequiresTransportBoundIdentityOrRouteProof() { + return java.util.stream.Stream.of("BUNGEECORD", "VELOCITY") + .flatMap(platform -> java.util.Arrays.stream(BungeeMethod.values()) + .map(method -> DynamicTest.dynamicTest(platform + " " + method, () -> { + VotingPluginProxyTestImpl proxy = new VotingPluginProxyTestImpl(); + proxy.setProxyPlatform(platform); + proxy.setMethod(method); + com.bencodez.simpleapi.servercomm.global.GlobalMessageProxyHandler messages = + new com.bencodez.simpleapi.servercomm.global.GlobalMessageProxyHandler() { + @Override public void sendMessage(String server, int delay, JsonEnvelope envelope) { } + }; + proxy.registerControlEnrollmentListenerForTest(messages); + java.util.UUID requestId = java.util.UUID.randomUUID(); + messages.onMessage(VotingPluginWire.controlEnrollmentRequest("Server1", "", + "http://control.example.test:2150", requestId)); + if (method == BungeeMethod.PLUGINMESSAGING || method == BungeeMethod.HTTP) { + assertEquals(null, proxy.getControlEnrollmentSource()); + assertEquals(null, proxy.getControlEnrollmentResult()); + return; + } + VotingPluginWire.ControlEnrollmentResult challenge = + VotingPluginWire.readControlEnrollmentResult(proxy.getControlEnrollmentResult()); + assertTrue(challenge.valid); + assertFalse(challenge.challenge.isEmpty()); + messages.onMessage(VotingPluginWire.controlEnrollmentRequest("Server1", "a".repeat(64), + "http://control.example.test:2150", requestId)); + assertEquals(null, proxy.getControlEnrollmentSource()); + JsonEnvelope proved = VotingPluginWire.controlEnrollmentRequest("Server1", "a".repeat(64), + "http://control.example.test:2150", requestId, challenge.challenge); + messages.onMessage(proved); + assertEquals("Server1", proxy.getControlEnrollmentSource()); + assertEquals(1, proxy.getControlEnrollmentInstallCount()); + messages.onMessage(proved); + assertEquals(1, proxy.getControlEnrollmentInstallCount()); + }))); + } + + @Test + void httpControlEnrollmentUsesAuthenticatedBackendIdentity() { + VotingPluginProxyTestImpl proxy = new VotingPluginProxyTestImpl(); + proxy.setMethod(BungeeMethod.HTTP); + java.util.UUID requestId = java.util.UUID.randomUUID(); + JsonEnvelope request = VotingPluginWire.controlEnrollmentRequest("Server1", "", + "http://control.example.test:2150", requestId); + + proxy.handleAuthenticatedHttpEnvelopeForTest(new HttpProxyTransportServer.ReceivedEnvelope( + "Server1", "message-1", request)); + + assertEquals("Server1", proxy.getControlEnrollmentSource()); + } + + @Test + void backendControlEnrollmentRejectsMismatchedTransportIdentity() { + VotingPluginProxyTestImpl proxy = new VotingPluginProxyTestImpl(); + proxy.setMethod(BungeeMethod.HTTP); + com.bencodez.simpleapi.servercomm.global.GlobalMessageProxyHandler messages = + new com.bencodez.simpleapi.servercomm.global.GlobalMessageProxyHandler() { + @Override public void sendMessage(String server, int delay, JsonEnvelope envelope) { } + }; + proxy.registerControlEnrollmentListenerForTest(messages); + JsonEnvelope mismatched = VotingPluginWire.controlEnrollmentRequest("Server1", "", + "http://control.example.test:2150", java.util.UUID.randomUUID()) + .toBuilder().put(VotingPluginWire.K_SERVER, "Server2").build(); + + messages.onMessage(mismatched); + + assertEquals(null, proxy.getControlEnrollmentSource()); + } + + @Test + void backendControlEnrollmentDoesNotChallengeAnExternalControlEndpoint() { + VotingPluginProxyTestImpl proxy = new VotingPluginProxyTestImpl(); + proxy.setMethod(BungeeMethod.REDIS); + proxy.setControlEnrollmentRouteProved(false); + com.bencodez.simpleapi.servercomm.global.GlobalMessageProxyHandler messages = + new com.bencodez.simpleapi.servercomm.global.GlobalMessageProxyHandler() { + @Override public void sendMessage(String server, int delay, JsonEnvelope envelope) { } + }; + proxy.registerControlEnrollmentListenerForTest(messages); + messages.onMessage(VotingPluginWire.controlEnrollmentRequest("Server1", "", + "http://external.example.test:2150", java.util.UUID.randomUUID())); + + VotingPluginWire.ControlEnrollmentResult result = + VotingPluginWire.readControlEnrollmentResult(proxy.getControlEnrollmentResult()); + assertTrue(result.valid); + assertFalse(result.success); + assertTrue(result.challenge.isEmpty()); + assertEquals(0, proxy.getControlEnrollmentInstallCount()); + } + @Test void httpMethodPreparationRejectsAnOccupiedPortBeforeConfigurationPublication() throws Exception { try (java.net.ServerSocket occupied = new java.net.ServerSocket(0, 1, 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 0f7e362ad6..a01b9c6753 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTestImpl.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTestImpl.java @@ -18,6 +18,7 @@ import com.bencodez.simpleapi.servercomm.global.GlobalMessageProxyHandler; import com.bencodez.votingplugin.proxy.OfflineBungeeVote; import com.bencodez.votingplugin.proxy.VotingPluginProxy; +import com.bencodez.votingplugin.proxy.VotingPluginWire; import com.bencodez.votingplugin.proxy.VotingPluginProxyConfig; import com.bencodez.votingplugin.proxy.cache.PendingVotePartyProxyEffects; import com.bencodez.simpleapi.servercomm.http.HttpProxyTransportServer; @@ -56,6 +57,11 @@ public class VotingPluginProxyTestImpl extends VotingPluginProxy { private Boolean pendingHttpTransportDeliveries; private volatile int reloadCoreCalls; private File dataFolder = new File("."); + private String proxyPlatform = "VELOCITY"; + private String controlEnrollmentSource; + private int controlEnrollmentInstallCount; + private JsonEnvelope controlEnrollmentResult; + private boolean controlEnrollmentRouteProved = true; public List getWarnings() { return warnings; @@ -155,7 +161,59 @@ public String getPluginVersion() { @Override public String getProxyPlatform() { - return "VELOCITY"; + return proxyPlatform; + } + + public void setProxyPlatform(String proxyPlatform) { + this.proxyPlatform = proxyPlatform; + } + + public void registerControlEnrollmentListenerForTest(GlobalMessageProxyHandler handler) { + registerControlEnrollmentListener(handler); + } + + public String getControlEnrollmentSource() { + return controlEnrollmentSource; + } + + public JsonEnvelope getControlEnrollmentResult() { + return controlEnrollmentResult; + } + + public int getControlEnrollmentInstallCount() { + return controlEnrollmentInstallCount; + } + + public void setControlEnrollmentRouteProved(boolean proved) { + controlEnrollmentRouteProved = proved; + } + + public void handleAuthenticatedHttpEnvelopeForTest(HttpProxyTransportServer.ReceivedEnvelope received) { + handleHttpTransportEnvelope(received); + } + + @Override + protected void handleControlEnrollmentRequest(String sourceServer, JsonEnvelope envelope) { + controlEnrollmentSource = sourceServer; + } + + @Override + protected void installControlEnrollmentRequest(String sourceServer, + VotingPluginWire.ControlEnrollmentRequest request) { + controlEnrollmentSource = sourceServer; + controlEnrollmentInstallCount++; + } + + @Override + protected java.util.concurrent.CompletableFuture proveControlEnrollmentRoute(String sourceServer, + String endpoint) { + return java.util.concurrent.CompletableFuture.completedFuture(controlEnrollmentRouteProved); + } + + @Override + protected void sendControlEnrollmentResult(String server, java.util.UUID requestId, boolean success, + String challenge) { + controlEnrollmentResult = VotingPluginWire.controlEnrollmentResult(server, requestId, success, challenge); } @Override diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginWireTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginWireTest.java index 7aacf8ccae..7a35953447 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginWireTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginWireTest.java @@ -183,19 +183,26 @@ public void chunkedPresenceSnapshotPreservesChunkMetadata() { public void controlEnrollmentRoundTripContainsVerifierIdentityAndRoute() { UUID requestId = UUID.randomUUID(); String verifier = "a".repeat(64); + String challenge = UUID.randomUUID().toString(); VotingPluginWire.ControlEnrollmentRequest request = VotingPluginWire.readControlEnrollmentRequest( - VotingPluginWire.controlEnrollmentRequest("survival", verifier, "http://10.0.0.5:2150", requestId)); + VotingPluginWire.controlEnrollmentRequest("survival", verifier, "http://10.0.0.5:2150", requestId, + challenge)); VotingPluginWire.ControlEnrollmentResult result = VotingPluginWire.readControlEnrollmentResult( - VotingPluginWire.controlEnrollmentResult("survival", requestId, true)); + VotingPluginWire.controlEnrollmentResult("survival", requestId, false, challenge)); assertTrue(request.valid); assertEquals("survival", request.nodeId); + assertEquals("survival", VotingPluginWire.controlEnrollmentRequest( + "survival", verifier, "http://10.0.0.5:2150", requestId) + .getFields().get(VotingPluginWire.K_SERVER)); assertEquals(verifier, request.verifier); assertEquals("http://10.0.0.5:2150", request.endpoint); + assertEquals(challenge, request.challenge); assertEquals(requestId, request.requestId); assertTrue(result.valid); - assertTrue(result.success); + assertFalse(result.success); + assertEquals(challenge, result.challenge); assertEquals(requestId, result.requestId); } diff --git a/docs/control-connector.md b/docs/control-connector.md index 711843d8ae..0ce9ad52c0 100644 --- a/docs/control-connector.md +++ b/docs/control-connector.md @@ -151,8 +151,8 @@ response envelope remains bounded and exists so the proxy connector can receive ### Bukkit full-configuration enrollment -Each backend is separately opt-in and receives its own credential so one node cannot impersonate another. With -`BungeeMethod: PLUGINMESSAGING`, configure each backend's `Config.yml` and point `Endpoint` at the proxy-hosted listener: +Each backend is separately opt-in and receives its own credential so one node cannot impersonate another. Configure each +backend's `Config.yml` and point `Endpoint` at the proxy-hosted listener: ```yaml Control: @@ -174,16 +174,16 @@ rejects `localhost`, `127.0.0.0/8`, and IPv6 loopback because those addresses re when the processes run on different machines. Blank `NodeId` reuses `BungeeSettings.Server`. The backend generates the raw credential locally and sends only its SHA-256 -verifier over the plugin-message channel. Velocity/BungeeCord binds the request to the actual backend server connection, -requires that identity to equal `BungeeSettings.Server`, installs the verifier in its hosted Control, and returns a -non-secret acknowledgement. The pending marker is removed only after the backend also authenticates to its configured -Control endpoint, so an incorrect endpoint cannot strand a newly generated credential. The raw credential never leaves the -backend. Requests retry safely after restarts and while no player is available to carry plugin messages. +verifier through the configured proxy transport. Plugin messaging and HTTP bind enrollment to the backend connection +identity. Redis, MQTT, sockets, and MySQL use a short-lived, single-use challenge returned only through the configured +backend route before the proxy accepts a verifier. The pending marker is removed only after the backend also authenticates +to its configured Control endpoint, so an incorrect endpoint cannot strand a newly generated credential. The raw +credential never leaves the backend. Requests retry safely after restarts and during temporary transport outages. If a Bukkit server hosts its own Control and its endpoint addresses that local listener, the same generation and verifier installation happen locally. External Control installations, custom backend node IDs that differ from -`BungeeSettings.Server`, and non-plugin-message transports retain manual WebUI/owner-command enrollment; an existing -nonblank credential file is always treated as manually managed and is never replaced. +`BungeeSettings.Server` retain manual WebUI/owner-command enrollment; an existing nonblank credential file is always +treated as manually managed and is never replaced. The Bukkit connector owns separate single-thread daemon executors for presence/configuration work and read-only inspections, and performs no Control I/O on the server thread. The inspection worker is cancelled on shutdown with a From bcf40b0655ebc7a60366052f55b4ce6121b16443 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:07:15 -0600 Subject: [PATCH 2/3] Authenticate backend enrollment off the server thread --- .../control/BackendControlAutoEnrollment.java | 60 +++++++++++++++-- .../ControlEnrollmentAuthenticator.java | 67 +++++++++++++++++++ .../votingplugin/proxy/VotingPluginProxy.java | 19 ++++++ .../votingplugin/proxy/VotingPluginWire.java | 15 ++++- .../BackendControlAutoEnrollmentTest.java | 28 +++++++- .../tests/VotingPluginProxyTest.java | 31 +++++++-- docs/control-agent-contract.md | 21 ++++++ docs/control-connector.md | 6 +- 8 files changed, 229 insertions(+), 18 deletions(-) create mode 100644 VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlEnrollmentAuthenticator.java diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlAutoEnrollment.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlAutoEnrollment.java index 88ae117454..6f1d5b023a 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlAutoEnrollment.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlAutoEnrollment.java @@ -2,6 +2,9 @@ import java.io.IOException; import java.util.UUID; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; @@ -11,6 +14,7 @@ import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope; import com.bencodez.votingplugin.VotingPluginMain; import com.bencodez.votingplugin.backendproxy.BackendProxyHandler; +import com.bencodez.votingplugin.proxy.BungeeMethod; import com.bencodez.votingplugin.proxy.VotingPluginWire; import com.bencodez.votingplugin.proxy.control.HostedControlManager; import com.bencodez.votingplugin.proxy.control.HostedControlManager.HostConfiguration; @@ -31,8 +35,12 @@ public final class BackendControlAutoEnrollment implements AutoCloseable { private final String credentialFile; private final String endpoint; private final UUID requestId = UUID.randomUUID(); + private final BungeeMethod method; + private final ControlEnrollmentAuthenticator authenticator; private final AtomicBoolean closed = new AtomicBoolean(); private volatile BukkitTask retryTask; + private volatile ScheduledExecutorService retryExecutor; + private volatile ScheduledFuture retryFuture; private PendingAutoEnrollment enrollment; private boolean verifierInstalled; private boolean connectorAuthenticated; @@ -40,12 +48,15 @@ public final class BackendControlAutoEnrollment implements AutoCloseable { private String routeChallenge = ""; private BackendControlAutoEnrollment(VotingPluginMain plugin, String nodeId, String credentialFile, - String endpoint, PendingAutoEnrollment enrollment) { + String endpoint, PendingAutoEnrollment enrollment, BungeeMethod method, + ControlEnrollmentAuthenticator authenticator) { this.plugin = plugin; this.nodeId = nodeId; this.credentialFile = credentialFile; this.endpoint = endpoint; this.enrollment = enrollment; + this.method = method; + this.authenticator = authenticator; } /** Prepares a verifier that this Bukkit process's own hosted Control will install. */ @@ -80,8 +91,11 @@ public static BackendControlAutoEnrollment create(VotingPluginMain plugin, HostC PendingAutoEnrollment pending = inspection.pending(); if (pending == null && inspection.credentialPresent()) return null; if (pending != null && !nodeId.equals(pending.nodeId())) pending = null; + BungeeMethod method = BungeeMethod.getByName(plugin.getBungeeSettings().getBungeeMethod()); + ControlEnrollmentAuthenticator authenticator = method == BungeeMethod.PLUGINMESSAGING || method == BungeeMethod.HTTP + ? null : ControlEnrollmentAuthenticator.load(plugin.getDataFolder().toPath().resolve("secretkey.key")); return new BackendControlAutoEnrollment(plugin, nodeId, credentialFile, - endpoint == null ? "" : endpoint.trim(), pending); + endpoint == null ? "" : endpoint.trim(), pending, method, authenticator); } public static String configuredNodeId(VotingPluginMain plugin, ConfigurationSection control) { @@ -109,31 +123,59 @@ private static String nodeId(VotingPluginMain plugin, ConfigurationSection contr } public synchronized void start() { - if (closed.get() || retryTask != null) return; + if (closed.get() || retryTask != null || retryFuture != null) return; try { - retryTask = plugin.getServer().getScheduler().runTaskTimer(plugin, this::send, 1L, RETRY_TICKS); + if (method == BungeeMethod.PLUGINMESSAGING) { + retryTask = plugin.getServer().getScheduler().runTaskTimer(plugin, this::sendSafely, 1L, RETRY_TICKS); + } else { + retryExecutor = Executors.newSingleThreadScheduledExecutor(runnable -> { + Thread thread = new Thread(runnable, "VotingPlugin-ControlEnrollment"); + thread.setDaemon(true); + return thread; + }); + retryFuture = retryExecutor.scheduleWithFixedDelay(this::sendSafely, 0L, 15L, TimeUnit.SECONDS); + } } catch (RuntimeException failure) { + ScheduledExecutorService executor = retryExecutor; + if (executor != null) executor.shutdownNow(); + retryExecutor = null; + retryFuture = null; closed.set(true); plugin.getLogger().warning("[Control] Automatic backend enrollment could not be scheduled"); } } + private void sendSafely() { + try { + send(); + } catch (RuntimeException failure) { + plugin.getLogger().warning("[Control] Automatic backend enrollment send failed; it will retry"); + plugin.debug(failure); + } + } + /** True while the proxy must confirm that this route reaches its hosted Control. */ public synchronized boolean isAwaitingCredential() { return enrollment == null; } - private void send() { + void send() { PendingAutoEnrollment pending; + String challenge; synchronized (this) { if (closed.get() || (verifierInstalled && System.nanoTime() - verifierAcknowledgedAt < VERIFIER_REFRESH_NANOS)) return; pending = enrollment; + challenge = routeChallenge; } BackendProxyHandler handler = plugin.getBackendProxyHandler(); if (handler == null || handler.getGlobalMessageHandler() == null) return; + String verifier = pending == null ? "" : pending.verifier(); + String proof = authenticator == null ? "" + : authenticator.sign(nodeId, requestId, endpoint, verifier, challenge); + if (closed.get()) return; handler.getGlobalMessageHandler().sendMessage(VotingPluginWire.controlEnrollmentRequest( - nodeId, pending == null ? "" : pending.verifier(), endpoint, requestId, routeChallenge)); + nodeId, verifier, endpoint, requestId, challenge, proof)); } public void handle(JsonEnvelope envelope) { @@ -205,5 +247,11 @@ public synchronized void close() { BukkitTask task = retryTask; if (task != null) task.cancel(); retryTask = null; + ScheduledFuture future = retryFuture; + if (future != null) future.cancel(true); + retryFuture = null; + ScheduledExecutorService executor = retryExecutor; + if (executor != null) executor.shutdownNow(); + retryExecutor = null; } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlEnrollmentAuthenticator.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlEnrollmentAuthenticator.java new file mode 100644 index 0000000000..da00df975f --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlEnrollmentAuthenticator.java @@ -0,0 +1,67 @@ +package com.bencodez.votingplugin.control; + +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.util.Base64; +import java.util.UUID; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +/** Authenticates proxy-mediated enrollment over shared, unbound transports. */ +public final class ControlEnrollmentAuthenticator { + private static final String ALGORITHM = "HmacSHA256"; + private final byte[] key; + + private ControlEnrollmentAuthenticator(byte[] key) { + this.key = key.clone(); + } + + public static ControlEnrollmentAuthenticator load(Path keyFile) throws IOException { + if (!Files.isRegularFile(keyFile)) { + throw new IOException("Control enrollment 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("Control enrollment key is too short"); + return new ControlEnrollmentAuthenticator(decoded); + } catch (IllegalArgumentException invalid) { + throw new IOException("Control enrollment key is invalid", invalid); + } + } + + public String sign(String nodeId, UUID requestId, String endpoint, String verifier, String challenge) { + try { + Mac mac = Mac.getInstance(ALGORITHM); + mac.init(new SecretKeySpec(key, ALGORITHM)); + update(mac, nodeId); + update(mac, requestId == null ? "" : requestId.toString()); + update(mac, endpoint); + update(mac, verifier); + update(mac, challenge); + return java.util.HexFormat.of().formatHex(mac.doFinal()); + } catch (GeneralSecurityException impossible) { + throw new IllegalStateException("HMAC-SHA256 is unavailable", impossible); + } + } + + public boolean verifies(String authenticator, String nodeId, UUID requestId, String endpoint, String verifier, + String challenge) { + if (authenticator == null || !authenticator.matches("[0-9a-f]{64}")) return false; + byte[] expected = sign(nodeId, requestId, endpoint, verifier, challenge).getBytes(StandardCharsets.US_ASCII); + return MessageDigest.isEqual(expected, authenticator.getBytes(StandardCharsets.US_ASCII)); + } + + private static void update(Mac mac, String value) { + byte[] bytes = value == null ? new byte[0] : value.getBytes(StandardCharsets.UTF_8); + mac.update((byte) (bytes.length >>> 24)); + mac.update((byte) (bytes.length >>> 16)); + mac.update((byte) (bytes.length >>> 8)); + mac.update((byte) bytes.length); + mac.update(bytes); + } +} 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 dbfe10c8c2..e92134c268 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java @@ -76,6 +76,7 @@ import com.bencodez.simpleapi.sql.data.DataValueInt; import com.bencodez.simpleapi.sql.data.DataValueString; import com.bencodez.simpleapi.sql.mysql.config.MysqlConfig; +import com.bencodez.votingplugin.control.ControlEnrollmentAuthenticator; import com.bencodez.votingplugin.proxy.broadcast.ProxyBroadcastDecider; import com.bencodez.votingplugin.proxy.cache.IVoteCache; import com.bencodez.votingplugin.proxy.cache.PendingVotePartyProxyEffects; @@ -335,6 +336,7 @@ private void close() { private final Set pendingBackendRecoverySnapshots = ConcurrentHashMap.newKeySet(); private final Map controlEnrollmentNextAllowed = new ConcurrentHashMap<>(); private final Map controlEnrollmentChallenges = new ConcurrentHashMap<>(); + private volatile ControlEnrollmentAuthenticator controlEnrollmentAuthenticator; private final Map pendingCommunicationTests = new ConcurrentHashMap<>(); private volatile ControlConnector controlConnector; private volatile HostedControlManager hostedControlManager; @@ -3322,6 +3324,7 @@ public void onReceive(JsonEnvelope message) { private void handleUnboundControlEnrollmentRequest(String sourceServer, JsonEnvelope envelope, VotingPluginWire.ControlEnrollmentRequest request) { + if (!verifyControlEnrollmentAuthenticator(request)) return; if (request.verifier.isEmpty()) { if (!allowControlEnrollmentAttempt(sourceServer)) return; requestControlEnrollmentChallenge(sourceServer, request); @@ -3345,6 +3348,22 @@ private void handleUnboundControlEnrollmentRequest(String sourceServer, JsonEnve installControlEnrollmentRequest(sourceServer, request); } + private boolean verifyControlEnrollmentAuthenticator(VotingPluginWire.ControlEnrollmentRequest request) { + try { + ControlEnrollmentAuthenticator authenticator = controlEnrollmentAuthenticator; + if (authenticator == null) { + authenticator = ControlEnrollmentAuthenticator.load( + getDataFolderPlugin().toPath().resolve("secretkey.key")); + controlEnrollmentAuthenticator = authenticator; + } + return authenticator.verifies(request.authenticator, request.nodeId, request.requestId, request.endpoint, + request.verifier, request.challenge); + } catch (IOException unavailable) { + debug("[Control] shared enrollment key is unavailable"); + return false; + } + } + protected void handleControlEnrollmentRequest(String sourceServer, JsonEnvelope envelope) { VotingPluginWire.ControlEnrollmentRequest request = VotingPluginWire.readControlEnrollmentRequest(envelope); if (!request.valid || sourceServer == null || sourceServer.isBlank()) return; diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginWire.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginWire.java index 55eab09c51..842792f37d 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginWire.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginWire.java @@ -97,6 +97,7 @@ private VotingPluginWire() { public static final String K_VERIFIER = "verifier"; public static final String K_ENDPOINT = "endpoint"; public static final String K_CHALLENGE = "challenge"; + public static final String K_AUTHENTICATOR = "authenticator"; public static final String K_SUCCESS = "success"; // Vote/VoteOnline extras @@ -231,10 +232,16 @@ public static JsonEnvelope controlEnrollmentRequest(String nodeId, String verifi public static JsonEnvelope controlEnrollmentRequest(String nodeId, String verifier, String endpoint, UUID requestId, String challenge) { + return controlEnrollmentRequest(nodeId, verifier, endpoint, requestId, challenge, ""); + } + + public static JsonEnvelope controlEnrollmentRequest(String nodeId, String verifier, String endpoint, UUID requestId, + String challenge, String authenticator) { return base(SUB_CONTROL_ENROLLMENT_REQUEST).put(K_NODE_ID, safe(nodeId)).put(K_SERVER, safe(nodeId)) .put(K_VERIFIER, safe(verifier)) .put(K_ENDPOINT, safe(endpoint)) .put(K_CHALLENGE, safe(challenge)) + .put(K_AUTHENTICATOR, safe(authenticator)) .put(K_REQUEST_ID, requestId == null ? "" : requestId.toString()).build(); } @@ -574,19 +581,22 @@ public static final class ControlEnrollmentRequest { public final String verifier; public final String endpoint; public final String challenge; + public final String authenticator; public final UUID requestId; public final boolean valid; private ControlEnrollmentRequest(String nodeId, String verifier, String endpoint, UUID requestId, - String challenge) { + String challenge, String authenticator) { this.nodeId = nodeId; this.verifier = verifier; this.endpoint = endpoint; this.challenge = challenge; + this.authenticator = authenticator; this.requestId = requestId; this.valid = nodeId.matches("[A-Za-z0-9][A-Za-z0-9._-]{0,63}") && (verifier.isEmpty() || verifier.matches("[0-9a-f]{64}")) && (challenge.isEmpty() || challenge.matches("[0-9a-f-]{36}")) + && (authenticator.isEmpty() || authenticator.matches("[0-9a-f]{64}")) && !endpoint.isBlank() && endpoint.length() <= 2048 && requestId != null; } @@ -595,7 +605,8 @@ private ControlEnrollmentRequest(String nodeId, String verifier, String endpoint public static ControlEnrollmentRequest readControlEnrollmentRequest(JsonEnvelope env) { Map fields = env.getFields(); return new ControlEnrollmentRequest(safe(fields.get(K_NODE_ID)), safe(fields.get(K_VERIFIER)), - safe(fields.get(K_ENDPOINT)), readUuid(fields, K_REQUEST_ID), safe(fields.get(K_CHALLENGE))); + safe(fields.get(K_ENDPOINT)), readUuid(fields, K_REQUEST_ID), safe(fields.get(K_CHALLENGE)), + safe(fields.get(K_AUTHENTICATOR))); } public static final class ControlEnrollmentResult { diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlAutoEnrollmentTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlAutoEnrollmentTest.java index 6d696d3c03..0bfa002b5d 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlAutoEnrollmentTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlAutoEnrollmentTest.java @@ -9,9 +9,13 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.times; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.timeout; import java.nio.file.Path; import java.nio.file.Files; +import java.util.Base64; +import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Stream; import org.bukkit.Server; @@ -47,6 +51,8 @@ Stream submitsEnrollmentThroughEveryBackendTransport() { private void submitEnrollment(BungeeMethod method) throws Exception { Path methodDirectory = directory.resolve(method.name()); Files.createDirectories(methodDirectory); + Files.writeString(methodDirectory.resolve("secretkey.key"), + Base64.getEncoder().encodeToString(new byte[32])); VotingPluginMain plugin = mock(VotingPluginMain.class); Config config = mock(Config.class); BungeeSettings bungee = mock(BungeeSettings.class); @@ -56,6 +62,11 @@ private void submitEnrollment(BungeeMethod method) throws Exception { BukkitTask task = mock(BukkitTask.class); BackendProxyHandler handler = mock(BackendProxyHandler.class); GlobalMessageHandler messages = mock(GlobalMessageHandler.class); + AtomicReference sendingThread = new AtomicReference<>(); + org.mockito.Mockito.doAnswer(invocation -> { + sendingThread.compareAndSet(null, Thread.currentThread().getName()); + return null; + }).when(messages).sendMessage(org.mockito.ArgumentMatchers.any()); YamlConfiguration data = new YamlConfiguration(); data.set("Control.Backend.Enabled", true); data.set("Control.Backend.Endpoint", "http://control.example.test:2150"); @@ -82,7 +93,11 @@ private void submitEnrollment(BungeeMethod method) throws Exception { assertNotNull(enrollment); assertTrue(enrollment.isAwaitingCredential()); enrollment.start(); - scheduled.getValue().run(); + if (method == BungeeMethod.PLUGINMESSAGING) scheduled.getValue().run(); + else verify(messages, timeout(1000)).sendMessage(org.mockito.ArgumentMatchers.any()); + if (method != BungeeMethod.PLUGINMESSAGING) { + assertTrue(sendingThread.get().startsWith("VotingPlugin-ControlEnrollment")); + } ArgumentCaptor initialRequest = ArgumentCaptor.forClass(JsonEnvelope.class); verify(messages).sendMessage(initialRequest.capture()); @@ -95,7 +110,7 @@ private void submitEnrollment(BungeeMethod method) throws Exception { enrollment.handle(VotingPluginWire.controlEnrollmentResult("backend-a", requestId, false, challenge)); verify(plugin).startBackendControlConnectorForEnrollment(enrollment); org.mockito.Mockito.clearInvocations(messages); - scheduled.getValue().run(); + enrollment.send(); ArgumentCaptor provedRequest = ArgumentCaptor.forClass(JsonEnvelope.class); verify(messages).sendMessage(provedRequest.capture()); @@ -105,13 +120,18 @@ private void submitEnrollment(BungeeMethod method) throws Exception { assertTrue(parsed.valid); assertEquals(challenge, parsed.challenge); assertEquals(64, parsed.verifier.length()); + if (method == BungeeMethod.PLUGINMESSAGING || method == BungeeMethod.HTTP) { + assertEquals("", parsed.authenticator); + } else { + assertEquals(64, parsed.authenticator.length()); + } // A repeated challenge retries connector startup without replacing this enrollment, // its request correlation, or the verifier that was already written. enrollment.handle(VotingPluginWire.controlEnrollmentResult("backend-a", requestId, false, challenge)); verify(plugin, times(2)).startBackendControlConnectorForEnrollment(enrollment); org.mockito.Mockito.clearInvocations(messages); - scheduled.getValue().run(); + enrollment.send(); ArgumentCaptor retriedRequest = ArgumentCaptor.forClass(JsonEnvelope.class); verify(messages).sendMessage(retriedRequest.capture()); VotingPluginWire.ControlEnrollmentRequest retried = @@ -119,6 +139,8 @@ private void submitEnrollment(BungeeMethod method) throws Exception { assertEquals(requestId, retried.requestId); assertEquals(challenge, retried.challenge); assertEquals(parsed.verifier, retried.verifier); + if (method != BungeeMethod.PLUGINMESSAGING) verify(scheduler, never()) + .runTaskTimer(eq(plugin), org.mockito.ArgumentMatchers.any(Runnable.class), anyLong(), anyLong()); } } } 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 fe99a514e6..7effbd8da2 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTest.java @@ -70,14 +70,23 @@ java.util.stream.Stream backendControlEnrollmentRequiresTransportBo VotingPluginProxyTestImpl proxy = new VotingPluginProxyTestImpl(); proxy.setProxyPlatform(platform); proxy.setMethod(method); + java.nio.file.Path keyDirectory = java.nio.file.Files.createTempDirectory("vp-enrollment-"); + java.nio.file.Files.writeString(keyDirectory.resolve("secretkey.key"), + java.util.Base64.getEncoder().encodeToString(new byte[32])); + proxy.setDataFolder(keyDirectory.toFile()); com.bencodez.simpleapi.servercomm.global.GlobalMessageProxyHandler messages = new com.bencodez.simpleapi.servercomm.global.GlobalMessageProxyHandler() { @Override public void sendMessage(String server, int delay, JsonEnvelope envelope) { } }; proxy.registerControlEnrollmentListenerForTest(messages); java.util.UUID requestId = java.util.UUID.randomUUID(); + String endpoint = "http://control.example.test:2150"; + com.bencodez.votingplugin.control.ControlEnrollmentAuthenticator signer = + com.bencodez.votingplugin.control.ControlEnrollmentAuthenticator.load( + keyDirectory.resolve("secretkey.key")); + String initialProof = signer.sign("Server1", requestId, endpoint, "", ""); messages.onMessage(VotingPluginWire.controlEnrollmentRequest("Server1", "", - "http://control.example.test:2150", requestId)); + endpoint, requestId, "", initialProof)); if (method == BungeeMethod.PLUGINMESSAGING || method == BungeeMethod.HTTP) { assertEquals(null, proxy.getControlEnrollmentSource()); assertEquals(null, proxy.getControlEnrollmentResult()); @@ -88,10 +97,12 @@ java.util.stream.Stream backendControlEnrollmentRequiresTransportBo assertTrue(challenge.valid); assertFalse(challenge.challenge.isEmpty()); messages.onMessage(VotingPluginWire.controlEnrollmentRequest("Server1", "a".repeat(64), - "http://control.example.test:2150", requestId)); + endpoint, requestId)); assertEquals(null, proxy.getControlEnrollmentSource()); + String responseProof = signer.sign("Server1", requestId, endpoint, "a".repeat(64), + challenge.challenge); JsonEnvelope proved = VotingPluginWire.controlEnrollmentRequest("Server1", "a".repeat(64), - "http://control.example.test:2150", requestId, challenge.challenge); + endpoint, requestId, challenge.challenge, responseProof); messages.onMessage(proved); assertEquals("Server1", proxy.getControlEnrollmentSource()); assertEquals(1, proxy.getControlEnrollmentInstallCount()); @@ -133,17 +144,25 @@ void backendControlEnrollmentRejectsMismatchedTransportIdentity() { } @Test - void backendControlEnrollmentDoesNotChallengeAnExternalControlEndpoint() { + void backendControlEnrollmentDoesNotChallengeAnExternalControlEndpoint() throws Exception { VotingPluginProxyTestImpl proxy = new VotingPluginProxyTestImpl(); proxy.setMethod(BungeeMethod.REDIS); + java.nio.file.Files.writeString(temporaryDirectory.resolve("secretkey.key"), + java.util.Base64.getEncoder().encodeToString(new byte[32])); + proxy.setDataFolder(temporaryDirectory.toFile()); proxy.setControlEnrollmentRouteProved(false); com.bencodez.simpleapi.servercomm.global.GlobalMessageProxyHandler messages = new com.bencodez.simpleapi.servercomm.global.GlobalMessageProxyHandler() { @Override public void sendMessage(String server, int delay, JsonEnvelope envelope) { } }; proxy.registerControlEnrollmentListenerForTest(messages); - messages.onMessage(VotingPluginWire.controlEnrollmentRequest("Server1", "", - "http://external.example.test:2150", java.util.UUID.randomUUID())); + String endpoint = "http://external.example.test:2150"; + java.util.UUID requestId = java.util.UUID.randomUUID(); + com.bencodez.votingplugin.control.ControlEnrollmentAuthenticator signer = + com.bencodez.votingplugin.control.ControlEnrollmentAuthenticator.load( + temporaryDirectory.resolve("secretkey.key")); + messages.onMessage(VotingPluginWire.controlEnrollmentRequest("Server1", "", endpoint, requestId, "", + signer.sign("Server1", requestId, endpoint, "", ""))); VotingPluginWire.ControlEnrollmentResult result = VotingPluginWire.readControlEnrollmentResult(proxy.getControlEnrollmentResult()); diff --git a/docs/control-agent-contract.md b/docs/control-agent-contract.md index b80e34f5d3..c813b9c08e 100644 --- a/docs/control-agent-contract.md +++ b/docs/control-agent-contract.md @@ -13,6 +13,27 @@ has three separate lanes: Do not translate an inspection request into a configuration operation. Do not add raw SQL, arbitrary commands, player enumeration, database browsing, filesystem paths, or generic key/value reads to either contract. +## Backend automatic enrollment + +A backend without a completed credential sends `ControlEnrollmentRequest` through its configured proxy transport. The +request identifies `nodeId`/`server`, `requestId`, the exact Control `endpoint`, an optional SHA-256 credential `verifier`, +an optional proxy `challenge`, and an optional `authenticator`. The proxy replies with `ControlEnrollmentResult`, echoing +the node and request IDs and carrying either a single-use challenge or the final success result. + +Plugin messaging binds the request to the proxy-observed source server. HTTP uses its authenticated backend transport +identity. Redis, MQTT, sockets, and MySQL have no authoritative per-message source identity, so both the initial request +and challenge response require an HMAC-SHA256 transcript authenticator derived from the deployment's shared +`secretkey.key`. The signed transcript includes node ID, request ID, endpoint, verifier, and challenge. A proxy rejects a +missing or invalid signature without consuming the pending challenge. These transports therefore require the same key on +the proxy and backend; otherwise automatic enrollment remains unavailable and the administrator must install the backend +credential manually. Raw credentials and key material never appear in the envelope. + +The proxy first proves that the requested endpoint is its hosted Control instance. Challenges expire after one minute +and are single use. The backend retains its pending credential until the proxy reports verifier installation and the +backend connector successfully authenticates to that exact endpoint. Enrollment retries may repeat the same request ID, +verifier, and valid challenge. Non-plugin-message sends run on a dedicated daemon worker; plugin messaging alone is sent +from the Bukkit scheduler because its transport requires the server-owned channel. + ## Verified plugin deployment (`plugin.deploy.v1`) The capability is advertised only when the node can identify a safe staging target. Control assigns work through diff --git a/docs/control-connector.md b/docs/control-connector.md index 0ce9ad52c0..d88d2c9d2e 100644 --- a/docs/control-connector.md +++ b/docs/control-connector.md @@ -176,7 +176,11 @@ when the processes run on different machines. Blank `NodeId` reuses `BungeeSettings.Server`. The backend generates the raw credential locally and sends only its SHA-256 verifier through the configured proxy transport. Plugin messaging and HTTP bind enrollment to the backend connection identity. Redis, MQTT, sockets, and MySQL use a short-lived, single-use challenge returned only through the configured -backend route before the proxy accepts a verifier. The pending marker is removed only after the backend also authenticates +backend route before the proxy accepts a verifier. Those shared transports also require every enrollment request and +challenge response to carry an HMAC-SHA256 transcript authenticator derived from the existing `secretkey.key`; copy the +same key to the proxy and each backend before enabling automatic enrollment. This signature covers the node ID, request +ID, endpoint, verifier, and challenge, so a party that can read and publish broker or database messages cannot substitute +its own Control verifier. The pending marker is removed only after the backend also authenticates to its configured Control endpoint, so an incorrect endpoint cannot strand a newly generated credential. The raw credential never leaves the backend. Requests retry safely after restarts and during temporary transport outages. From 37fcf39908ebcadc49ba9741b3c203f5eab154ac Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Wed, 23 Sep 2026 18:07:36 -0600 Subject: [PATCH 3/3] Authenticate Control enrollment responses --- .../control/BackendControlAutoEnrollment.java | 6 ++-- .../ControlEnrollmentAuthenticator.java | 32 +++++++++++++++++-- .../votingplugin/proxy/VotingPluginProxy.java | 26 +++++++++++++-- .../votingplugin/proxy/VotingPluginWire.java | 14 ++++++-- .../BackendControlAutoEnrollmentTest.java | 15 +++++++-- .../tests/VotingPluginProxyTest.java | 10 ++++-- .../tests/VotingPluginProxyTestImpl.java | 2 +- .../tests/VotingPluginWireTest.java | 1 + 8 files changed, 90 insertions(+), 16 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlAutoEnrollment.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlAutoEnrollment.java index 6f1d5b023a..fbd2a8e570 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlAutoEnrollment.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlAutoEnrollment.java @@ -172,7 +172,7 @@ void send() { if (handler == null || handler.getGlobalMessageHandler() == null) return; String verifier = pending == null ? "" : pending.verifier(); String proof = authenticator == null ? "" - : authenticator.sign(nodeId, requestId, endpoint, verifier, challenge); + : authenticator.signRequest(nodeId, requestId, endpoint, verifier, challenge); if (closed.get()) return; handler.getGlobalMessageHandler().sendMessage(VotingPluginWire.controlEnrollmentRequest( nodeId, verifier, endpoint, requestId, challenge, proof)); @@ -180,9 +180,11 @@ void send() { public void handle(JsonEnvelope envelope) { VotingPluginWire.ControlEnrollmentResult result = VotingPluginWire.readControlEnrollmentResult(envelope); + if (!result.valid || (authenticator != null && !authenticator.verifiesResult(result.authenticator, + result.nodeId, result.requestId, result.success, result.challenge))) return; boolean ensureConnector = false; synchronized (this) { - if (closed.get() || !result.valid || !requestId.equals(result.requestId) + if (closed.get() || !requestId.equals(result.requestId) || !nodeId.equals(result.nodeId)) return; if (!result.challenge.isEmpty()) { routeChallenge = result.challenge; diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlEnrollmentAuthenticator.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlEnrollmentAuthenticator.java index da00df975f..9fb57924ee 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlEnrollmentAuthenticator.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlEnrollmentAuthenticator.java @@ -15,6 +15,8 @@ /** Authenticates proxy-mediated enrollment over shared, unbound transports. */ public final class ControlEnrollmentAuthenticator { private static final String ALGORITHM = "HmacSHA256"; + private static final String REQUEST_DOMAIN = "votingplugin-control-enrollment-request-v1"; + private static final String RESULT_DOMAIN = "votingplugin-control-enrollment-result-v1"; private final byte[] key; private ControlEnrollmentAuthenticator(byte[] key) { @@ -34,10 +36,11 @@ public static ControlEnrollmentAuthenticator load(Path keyFile) throws IOExcepti } } - public String sign(String nodeId, UUID requestId, String endpoint, String verifier, String challenge) { + public String signRequest(String nodeId, UUID requestId, String endpoint, String verifier, String challenge) { try { Mac mac = Mac.getInstance(ALGORITHM); mac.init(new SecretKeySpec(key, ALGORITHM)); + update(mac, REQUEST_DOMAIN); update(mac, nodeId); update(mac, requestId == null ? "" : requestId.toString()); update(mac, endpoint); @@ -49,10 +52,33 @@ public String sign(String nodeId, UUID requestId, String endpoint, String verifi } } - public boolean verifies(String authenticator, String nodeId, UUID requestId, String endpoint, String verifier, + public String signResult(String nodeId, UUID requestId, boolean success, String challenge) { + try { + Mac mac = Mac.getInstance(ALGORITHM); + mac.init(new SecretKeySpec(key, ALGORITHM)); + update(mac, RESULT_DOMAIN); + update(mac, nodeId); + update(mac, requestId == null ? "" : requestId.toString()); + update(mac, Boolean.toString(success)); + update(mac, challenge); + return java.util.HexFormat.of().formatHex(mac.doFinal()); + } catch (GeneralSecurityException impossible) { + throw new IllegalStateException("HMAC-SHA256 is unavailable", impossible); + } + } + + public boolean verifiesRequest(String authenticator, String nodeId, UUID requestId, String endpoint, String verifier, + String challenge) { + if (authenticator == null || !authenticator.matches("[0-9a-f]{64}")) return false; + byte[] expected = signRequest(nodeId, requestId, endpoint, verifier, challenge) + .getBytes(StandardCharsets.US_ASCII); + return MessageDigest.isEqual(expected, authenticator.getBytes(StandardCharsets.US_ASCII)); + } + + public boolean verifiesResult(String authenticator, String nodeId, UUID requestId, boolean success, String challenge) { if (authenticator == null || !authenticator.matches("[0-9a-f]{64}")) return false; - byte[] expected = sign(nodeId, requestId, endpoint, verifier, challenge).getBytes(StandardCharsets.US_ASCII); + byte[] expected = signResult(nodeId, requestId, success, challenge).getBytes(StandardCharsets.US_ASCII); return MessageDigest.isEqual(expected, authenticator.getBytes(StandardCharsets.US_ASCII)); } 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 e92134c268..fd917df8b1 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java @@ -3356,7 +3356,7 @@ private boolean verifyControlEnrollmentAuthenticator(VotingPluginWire.ControlEnr getDataFolderPlugin().toPath().resolve("secretkey.key")); controlEnrollmentAuthenticator = authenticator; } - return authenticator.verifies(request.authenticator, request.nodeId, request.requestId, request.endpoint, + return authenticator.verifiesRequest(request.authenticator, request.nodeId, request.requestId, request.endpoint, request.verifier, request.challenge); } catch (IOException unavailable) { debug("[Control] shared enrollment key is unavailable"); @@ -3437,8 +3437,28 @@ protected void sendControlEnrollmentResult(String server, UUID requestId, boolea protected void sendControlEnrollmentResult(String server, UUID requestId, boolean success, String challenge) { GlobalMessageProxyHandler handler = globalMessageProxyHandler; - if (handler != null) handler.sendMessage(server, 0, - VotingPluginWire.controlEnrollmentResult(server, requestId, success, challenge)); + JsonEnvelope result = createControlEnrollmentResult(server, requestId, success, challenge); + if (handler != null && result != null) handler.sendMessage(server, 0, result); + } + + protected JsonEnvelope createControlEnrollmentResult(String server, UUID requestId, boolean success, + String challenge) { + String proof = ""; + if (method != BungeeMethod.PLUGINMESSAGING && method != BungeeMethod.HTTP) { + try { + ControlEnrollmentAuthenticator authenticator = controlEnrollmentAuthenticator; + if (authenticator == null) { + authenticator = ControlEnrollmentAuthenticator.load( + getDataFolderPlugin().toPath().resolve("secretkey.key")); + controlEnrollmentAuthenticator = authenticator; + } + proof = authenticator.signResult(server, requestId, success, challenge); + } catch (IOException unavailable) { + debug("[Control] shared enrollment key is unavailable"); + return null; + } + } + return VotingPluginWire.controlEnrollmentResult(server, requestId, success, challenge, proof); } private UUID parseUUIDFromString(String uuidAsString) { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginWire.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginWire.java index 842792f37d..d4d7813162 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginWire.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginWire.java @@ -251,9 +251,15 @@ public static JsonEnvelope controlEnrollmentResult(String nodeId, UUID requestId public static JsonEnvelope controlEnrollmentResult(String nodeId, UUID requestId, boolean success, String challenge) { + return controlEnrollmentResult(nodeId, requestId, success, challenge, ""); + } + + public static JsonEnvelope controlEnrollmentResult(String nodeId, UUID requestId, boolean success, + String challenge, String authenticator) { return base(SUB_CONTROL_ENROLLMENT_RESULT).put(K_NODE_ID, safe(nodeId)) .put(K_REQUEST_ID, requestId == null ? "" : requestId.toString()) .put(K_CHALLENGE, safe(challenge)) + .put(K_AUTHENTICATOR, safe(authenticator)) .put(K_SUCCESS, success).build(); } @@ -614,13 +620,16 @@ public static final class ControlEnrollmentResult { public final UUID requestId; public final boolean success; public final String challenge; + public final String authenticator; public final boolean valid; - private ControlEnrollmentResult(String nodeId, UUID requestId, boolean success, String challenge) { + private ControlEnrollmentResult(String nodeId, UUID requestId, boolean success, String challenge, + String authenticator) { this.nodeId = nodeId; this.requestId = requestId; this.success = success; this.challenge = challenge; + this.authenticator = authenticator; this.valid = nodeId.matches("[A-Za-z0-9][A-Za-z0-9._-]{0,63}") && requestId != null && (challenge.isEmpty() || challenge.matches("[0-9a-f-]{36}")); } @@ -629,7 +638,8 @@ private ControlEnrollmentResult(String nodeId, UUID requestId, boolean success, public static ControlEnrollmentResult readControlEnrollmentResult(JsonEnvelope env) { Map fields = env.getFields(); return new ControlEnrollmentResult(safe(fields.get(K_NODE_ID)), readUuid(fields, K_REQUEST_ID), - readBool(fields, K_SUCCESS, false), safe(fields.get(K_CHALLENGE))); + readBool(fields, K_SUCCESS, false), safe(fields.get(K_CHALLENGE)), + safe(fields.get(K_AUTHENTICATOR))); } public static final class PresencePlayer { diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlAutoEnrollmentTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlAutoEnrollmentTest.java index 0bfa002b5d..741e7ea77b 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlAutoEnrollmentTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlAutoEnrollmentTest.java @@ -107,7 +107,17 @@ private void submitEnrollment(BungeeMethod method) throws Exception { java.util.UUID requestId = java.util.UUID.fromString( initial.getFields().get(VotingPluginWire.K_REQUEST_ID)); String challenge = java.util.UUID.randomUUID().toString(); - enrollment.handle(VotingPluginWire.controlEnrollmentResult("backend-a", requestId, false, challenge)); + ControlEnrollmentAuthenticator signer = ControlEnrollmentAuthenticator.load( + methodDirectory.resolve("secretkey.key")); + String resultProof = method == BungeeMethod.PLUGINMESSAGING || method == BungeeMethod.HTTP ? "" + : signer.signResult("backend-a", requestId, false, challenge); + if (!resultProof.isEmpty()) { + enrollment.handle(VotingPluginWire.controlEnrollmentResult( + "backend-a", requestId, false, challenge, "0".repeat(64))); + verify(plugin, never()).startBackendControlConnectorForEnrollment(enrollment); + } + enrollment.handle(VotingPluginWire.controlEnrollmentResult( + "backend-a", requestId, false, challenge, resultProof)); verify(plugin).startBackendControlConnectorForEnrollment(enrollment); org.mockito.Mockito.clearInvocations(messages); enrollment.send(); @@ -128,7 +138,8 @@ private void submitEnrollment(BungeeMethod method) throws Exception { // A repeated challenge retries connector startup without replacing this enrollment, // its request correlation, or the verifier that was already written. - enrollment.handle(VotingPluginWire.controlEnrollmentResult("backend-a", requestId, false, challenge)); + enrollment.handle(VotingPluginWire.controlEnrollmentResult( + "backend-a", requestId, false, challenge, resultProof)); verify(plugin, times(2)).startBackendControlConnectorForEnrollment(enrollment); org.mockito.Mockito.clearInvocations(messages); enrollment.send(); 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 7effbd8da2..c48b65488c 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTest.java @@ -84,7 +84,7 @@ java.util.stream.Stream backendControlEnrollmentRequiresTransportBo com.bencodez.votingplugin.control.ControlEnrollmentAuthenticator signer = com.bencodez.votingplugin.control.ControlEnrollmentAuthenticator.load( keyDirectory.resolve("secretkey.key")); - String initialProof = signer.sign("Server1", requestId, endpoint, "", ""); + String initialProof = signer.signRequest("Server1", requestId, endpoint, "", ""); messages.onMessage(VotingPluginWire.controlEnrollmentRequest("Server1", "", endpoint, requestId, "", initialProof)); if (method == BungeeMethod.PLUGINMESSAGING || method == BungeeMethod.HTTP) { @@ -96,10 +96,12 @@ java.util.stream.Stream backendControlEnrollmentRequiresTransportBo VotingPluginWire.readControlEnrollmentResult(proxy.getControlEnrollmentResult()); assertTrue(challenge.valid); assertFalse(challenge.challenge.isEmpty()); + assertTrue(signer.verifiesResult(challenge.authenticator, challenge.nodeId, + challenge.requestId, challenge.success, challenge.challenge)); messages.onMessage(VotingPluginWire.controlEnrollmentRequest("Server1", "a".repeat(64), endpoint, requestId)); assertEquals(null, proxy.getControlEnrollmentSource()); - String responseProof = signer.sign("Server1", requestId, endpoint, "a".repeat(64), + String responseProof = signer.signRequest("Server1", requestId, endpoint, "a".repeat(64), challenge.challenge); JsonEnvelope proved = VotingPluginWire.controlEnrollmentRequest("Server1", "a".repeat(64), endpoint, requestId, challenge.challenge, responseProof); @@ -162,13 +164,15 @@ void backendControlEnrollmentDoesNotChallengeAnExternalControlEndpoint() throws com.bencodez.votingplugin.control.ControlEnrollmentAuthenticator.load( temporaryDirectory.resolve("secretkey.key")); messages.onMessage(VotingPluginWire.controlEnrollmentRequest("Server1", "", endpoint, requestId, "", - signer.sign("Server1", requestId, endpoint, "", ""))); + signer.signRequest("Server1", requestId, endpoint, "", ""))); VotingPluginWire.ControlEnrollmentResult result = VotingPluginWire.readControlEnrollmentResult(proxy.getControlEnrollmentResult()); assertTrue(result.valid); assertFalse(result.success); assertTrue(result.challenge.isEmpty()); + assertTrue(signer.verifiesResult(result.authenticator, result.nodeId, + result.requestId, result.success, result.challenge)); assertEquals(0, proxy.getControlEnrollmentInstallCount()); } 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 a01b9c6753..610a77c1b4 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTestImpl.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTestImpl.java @@ -213,7 +213,7 @@ protected java.util.concurrent.CompletableFuture proveControlEnrollment @Override protected void sendControlEnrollmentResult(String server, java.util.UUID requestId, boolean success, String challenge) { - controlEnrollmentResult = VotingPluginWire.controlEnrollmentResult(server, requestId, success, challenge); + controlEnrollmentResult = createControlEnrollmentResult(server, requestId, success, challenge); } @Override diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginWireTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginWireTest.java index 7a35953447..ff591c5e63 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginWireTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginWireTest.java @@ -204,6 +204,7 @@ public void controlEnrollmentRoundTripContainsVerifierIdentityAndRoute() { assertFalse(result.success); assertEquals(challenge, result.challenge); assertEquals(requestId, result.requestId); + assertEquals("", result.authenticator); } @Test