From 7febfa275af439f0aecf21b20e5327c43c2839b0 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 12:01:25 -0600 Subject: [PATCH 1/6] Allow installing caller-generated node verifiers --- .../control/auth/CredentialStore.java | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/main/java/com/bencodez/votingplugin/control/auth/CredentialStore.java b/src/main/java/com/bencodez/votingplugin/control/auth/CredentialStore.java index 1df4682f..b7c9c4a5 100644 --- a/src/main/java/com/bencodez/votingplugin/control/auth/CredentialStore.java +++ b/src/main/java/com/bencodez/votingplugin/control/auth/CredentialStore.java @@ -31,6 +31,7 @@ /** Persistent token verifiers and a salted PBKDF2 WebUI password verifier. */ public final class CredentialStore { private static final Pattern NODE_ID = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,63}"); + private static final Pattern SHA256_VERIFIER = Pattern.compile("[0-9a-f]{64}"); private static final int MAX_ENROLLMENTS = 10_000; private static final int MAX_STORE_BYTES = 2 * 1024 * 1024; private static final int WEB_PASSWORD_ITERATIONS = 600_000; @@ -74,6 +75,24 @@ public String rotateNode(String nodeId) throws IOException { return token; } + /** + * Installs a caller-generated SHA-256 node verifier without transferring the + * raw 256-bit credential. Used by a supervising VotingPlugin to enroll + * authenticated backend-server identities. + */ + public void installNodeVerifier(String nodeId, String verifier) throws IOException { + validateNodeId(nodeId); + if (verifier == null || !SHA256_VERIFIER.matcher(verifier).matches()) { + throw new IllegalArgumentException("verifier must be a lowercase SHA-256 digest"); + } + mutate(data -> { + if (!data.nodeHashes.containsKey(nodeId) && data.nodeHashes.size() >= MAX_ENROLLMENTS) { + throw new IllegalStateException("Maximum enrollment count reached"); + } + data.nodeHashes.put(nodeId, verifier); + }); + } + public void revokeNode(String nodeId) throws IOException { validateNodeId(nodeId); mutate(data -> data.nodeHashes.remove(nodeId)); From 55d40ea7168f3e496884b7fc1afe5ba1260598cc Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 12:01:33 -0600 Subject: [PATCH 2/6] Add hosted verifier enrollment command --- .../votingplugin/control/ControlApplication.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/main/java/com/bencodez/votingplugin/control/ControlApplication.java b/src/main/java/com/bencodez/votingplugin/control/ControlApplication.java index 89c0ddbc..e3606bdf 100644 --- a/src/main/java/com/bencodez/votingplugin/control/ControlApplication.java +++ b/src/main/java/com/bencodez/votingplugin/control/ControlApplication.java @@ -72,6 +72,15 @@ static void runOwnerCommand(String[] args, Map environment, Prin Path selected = ownerDataDirectory(args.length == 3 ? args[2] : null, environment); output.println(new CredentialStore(selected).rotateNode(args[1])); } + case "enroll-verifier" -> { + if (args.length < 3 || args.length > 4) { + throw new IllegalArgumentException( + "Usage: enroll-verifier [dataDirectory]"); + } + Path selected = ownerDataDirectory(args.length == 4 ? args[3] : null, environment); + new CredentialStore(selected).installNodeVerifier(args[1], args[2]); + output.println("Enrollment verifier installed."); + } case "revoke" -> { if (args.length < 2 || args.length > 3) { throw new IllegalArgumentException("Usage: revoke [dataDirectory]"); From daac5f6247d8e9e22ec2a0e88f7a80b7ceb2377e Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 12:01:43 -0600 Subject: [PATCH 3/6] Test caller-generated verifier enrollment --- .../control/auth/CredentialStoreTest.java | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/test/java/com/bencodez/votingplugin/control/auth/CredentialStoreTest.java b/src/test/java/com/bencodez/votingplugin/control/auth/CredentialStoreTest.java index a973062f..6f61bd6a 100644 --- a/src/test/java/com/bencodez/votingplugin/control/auth/CredentialStoreTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/auth/CredentialStoreTest.java @@ -26,6 +26,20 @@ class CredentialStoreTest { assertFalse(store.verifyNode("proxy-a", rotated)); } + @Test void callerGeneratedVerifierEnrollsWithoutPersistingTheRawCredential() throws Exception { + CredentialStore store = new CredentialStore(directory); + String credential = "vpctl_node_locally_generated_credential"; + String verifier = java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256") + .digest(credential.getBytes(java.nio.charset.StandardCharsets.UTF_8))); + + store.installNodeVerifier("backend-lobby", verifier); + + assertTrue(store.verifyNode("backend-lobby", credential)); + assertFalse(Files.readString(directory.resolve("credentials.json")).contains(credential)); + assertThrows(IllegalArgumentException.class, + () -> store.installNodeVerifier("backend-lobby", "not-a-verifier")); + } + @Test void adminCredentialRotatesAndRawSecretIsNeverPersisted() throws Exception { CredentialStore store = new CredentialStore(directory); assertFalse(store.hasAdmin()); From 0ef79e82ecbc56893dc2b35749cd81c751b71bd9 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 12:01:53 -0600 Subject: [PATCH 4/6] Test verifier enrollment owner command --- .../control/ControlApplicationTest.java | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/test/java/com/bencodez/votingplugin/control/ControlApplicationTest.java b/src/test/java/com/bencodez/votingplugin/control/ControlApplicationTest.java index 66a3b1bc..d172d65c 100644 --- a/src/test/java/com/bencodez/votingplugin/control/ControlApplicationTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/ControlApplicationTest.java @@ -83,6 +83,22 @@ class ControlApplicationTest { assertTrue(output.toString().trim().startsWith("vpctl_admin_")); } + @Test void verifierEnrollmentCommandInstallsOnlyTheCallerGeneratedHash() throws Exception { + String credential = "vpctl_node_backend_generated_credential"; + String verifier = java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256") + .digest(credential.getBytes(java.nio.charset.StandardCharsets.UTF_8))); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + + ControlApplication.runOwnerCommand( + new String[]{"enroll-verifier", "backend-lobby", verifier, directory.toString()}, + java.util.Map.of(), new PrintStream(output)); + + assertTrue(new com.bencodez.votingplugin.control.auth.CredentialStore(directory) + .verifyNode("backend-lobby", credential)); + assertEquals("Enrollment verifier installed.", output.toString().trim()); + assertFalse(Files.readString(directory.resolve("credentials.json")).contains(credential)); + } + @Test void webPasswordCommandReadsSecretOutOfBand() throws Exception { ByteArrayOutputStream output = new ByteArrayOutputStream(); char[] supplied = "command-password-value".toCharArray(); From bcccab113f4940e87dfeccbf63e95c1ec1575290 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 12:02:03 -0600 Subject: [PATCH 5/6] Document supervised verifier enrollment --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index 21957194..4d88a7a3 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,10 @@ the node. The same page lists enrolled IDs and can revoke them. This is the norm Owner commands remain available for offline recovery and automation. They modify the configured data directory and print a new secret once. Stop shell history capture or otherwise handle the output as a password. +A supervising VotingPlugin may instead use the internal `enroll-verifier` owner command. Each node generates and retains +its own 256-bit credential and sends only its lowercase SHA-256 verifier through the trusted proxy/backend enrollment +handshake. The raw credential never crosses that channel or enters Control's process arguments. + ```shell # Create or rotate a credential bound to proxy-a java -jar target/votingplugin-control-0.1.0-SNAPSHOT-all.jar enroll proxy-a data @@ -90,6 +94,9 @@ java -jar target/votingplugin-control-0.1.0-SNAPSHOT-all.jar enroll proxy-a data # Enroll every Bukkit backend separately when full file control is wanted java -jar target/votingplugin-control-0.1.0-SNAPSHOT-all.jar enroll backend-lobby data +# Install a verifier generated by a supervised node (internal hosting automation) +java -jar target/votingplugin-control-0.1.0-SNAPSHOT-all.jar enroll-verifier backend-lobby data + # Immediately revoke it java -jar target/votingplugin-control-0.1.0-SNAPSHOT-all.jar revoke proxy-a data From 2d5c16c23314d8073a2bf41af3fe1386ca9cce95 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 12:25:09 -0600 Subject: [PATCH 6/6] Retry transient Maven release downloads --- .github/workflows/release.yml | 40 +++++++++++++++++++++++++++++------ 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 571a5db2..6dafc20c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -154,16 +154,42 @@ jobs: fi echo "RELEASE_OUTPUT_TIMESTAMP=$(git show -s --format=%cI "$RELEASE_SHA")" >> "$GITHUB_ENV" - name: Set release version - run: >- - mvn -B versions:set - -DnewVersion="$RELEASE_VERSION" - -DgenerateBackupPoms=false - -Dproject.build.outputTimestamp="$RELEASE_OUTPUT_TIMESTAMP" + run: | + maven_retry() { + local attempt + for attempt in 1 2 3 4; do + if mvn -B -U "$@"; then + return 0 + fi + if [[ "$attempt" == "4" ]]; then + return 1 + fi + echo "::warning::Maven attempt $attempt failed; retrying transient repository access" + sleep $((attempt * 15)) + done + } + maven_retry versions:set \ + -DnewVersion="$RELEASE_VERSION" \ + -DgenerateBackupPoms=false \ + -Dproject.build.outputTimestamp="$RELEASE_OUTPUT_TIMESTAMP" - name: Build and verify reproducibility run: | - mvn -B clean verify -Dproject.build.outputTimestamp="$RELEASE_OUTPUT_TIMESTAMP" + maven_retry() { + local attempt + for attempt in 1 2 3 4; do + if mvn -B -U "$@"; then + return 0 + fi + if [[ "$attempt" == "4" ]]; then + return 1 + fi + echo "::warning::Maven attempt $attempt failed; retrying transient repository access" + sleep $((attempt * 15)) + done + } + maven_retry clean verify -Dproject.build.outputTimestamp="$RELEASE_OUTPUT_TIMESTAMP" first_digest="$(sha256sum "target/votingplugin-control-$RELEASE_VERSION-all.jar" | cut -d ' ' -f 1)" - mvn -B clean verify -Dproject.build.outputTimestamp="$RELEASE_OUTPUT_TIMESTAMP" + maven_retry clean verify -Dproject.build.outputTimestamp="$RELEASE_OUTPUT_TIMESTAMP" second_digest="$(sha256sum "target/votingplugin-control-$RELEASE_VERSION-all.jar" | cut -d ' ' -f 1)" if [[ "$first_digest" != "$second_digest" ]]; then echo "::error::Release JAR is not byte-reproducible"