Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 33 additions & 7 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,13 +83,20 @@ 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

# 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 <sha256-verifier> data

# Immediately revoke it
java -jar target/votingplugin-control-0.1.0-SNAPSHOT-all.jar revoke proxy-a data

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,15 @@ static void runOwnerCommand(String[] args, Map<String, String> 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 <nodeId> <sha256Verifier> [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 <nodeId> [dataDirectory]");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
Loading