diff --git a/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteReceiver.java b/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteReceiver.java index d1cc589..20407a2 100644 --- a/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteReceiver.java +++ b/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteReceiver.java @@ -240,6 +240,8 @@ public void run() { debug("Reading V1 vote block (256 bytes expected) at " + startTime + " ms"); if (in.available() < 256) { + logWarning("Invalid vote format: Insufficient data for V1 vote block from " + address + + " (expected 256 bytes)"); debug("Insufficient data available for V1 vote block; closing connection from " + address); writer.close(); in.close(); @@ -263,20 +265,26 @@ public void run() { try { decrypted = RSA.decrypt(block, getKeyPair().getPrivate()); } catch (BadPaddingException e) { + // Log only the first 32 bytes as hex to avoid exposing full vote block StringBuilder blockHex = new StringBuilder(); - for (byte b : block) { - blockHex.append(String.format("%02X ", b)); + int bytesToLog = Math.min(32, block.length); + for (int i = 0; i < bytesToLog; i++) { + blockHex.append(String.format("%02X ", block[i])); + } + if (block.length > bytesToLog) { + blockHex.append("... (truncated)"); } logWarning( - "Decryption failed. Either the vote block is invalid or the public key does not match the server list from " + "Decryption failed: Invalid V1 vote block or public key mismatch from " + address); + debug("Vote block preview (first 32 bytes): " + blockHex.toString().trim()); throw e; } int position = 0; String opcode = readString(decrypted, position); position += opcode.length() + 1; if (!opcode.equals("VOTE")) { - throw new Exception("Unable to decode RSA: invalid opcode " + opcode); + throw new Exception("Invalid vote format: Expected opcode 'VOTE' but got '" + opcode + "' from " + address); } String serviceName = readString(decrypted, position); position += serviceName.length() + 1; @@ -290,6 +298,8 @@ public void run() { + "\n"; debug("Processed V1 vote block."); } else { + logWarning("Invalid vote format: Failed to read complete V1 vote block from " + address + + " (expected 256 bytes, got " + totalRead + ")"); debug("Failed to read V1 vote, random ping? expected 256 bytes, got " + totalRead); continue; // throw new Exception("Failed to read V1 vote block: expected 256 bytes, got " @@ -322,7 +332,7 @@ public void run() { int jsonEnd = voteData.lastIndexOf("}"); if (jsonStart == -1 || jsonEnd == -1 || jsonStart > jsonEnd) { throw new Exception( - "Expected JSON-formatted vote payload, got: " + voteData + " from " + address); + "Invalid vote format: Expected JSON-formatted vote payload, got: " + voteData + " from " + address); } String jsonPayloadRaw = voteData.substring(jsonStart, jsonEnd + 1).trim(); debug("Extracted raw JSON payload: [" + jsonPayloadRaw + "]"); @@ -332,20 +342,53 @@ public void run() { if (jsonPayloadRaw.startsWith("[")) { JsonArray jsonArray = gson.fromJson(jsonPayloadRaw, JsonArray.class); if (jsonArray.size() == 0) { - throw new Exception("Empty JSON array in vote payload from " + address); + throw new Exception("Invalid vote format: Empty JSON array in vote payload from " + address); } voteMessage = jsonArray.get(0).getAsJsonObject(); } else { voteMessage = gson.fromJson(jsonPayloadRaw, JsonObject.class); } - // Extract the inner payload and signature. + // Validate and extract the outer payload and signature fields. + if (!voteMessage.has("payload")) { + throw new Exception("Invalid vote format: Missing required 'payload' field in outer JSON from " + address); + } + if (!voteMessage.has("signature")) { + throw new Exception("Invalid vote format: Missing required 'signature' field in outer JSON from " + address); + } String payload = voteMessage.get("payload").getAsString(); String sigHash = voteMessage.get("signature").getAsString(); - byte[] sigBytes = Base64.getDecoder().decode(sigHash); + byte[] sigBytes; + try { + sigBytes = Base64.getDecoder().decode(sigHash); + } catch (IllegalArgumentException e) { + throw new Exception("Invalid vote format: Signature is not valid Base64 from " + address + ": " + e.getMessage()); + } // Parse the inner payload JSON. - JsonObject votePayload = gson.fromJson(payload, JsonObject.class); + JsonObject votePayload; + try { + votePayload = gson.fromJson(payload, JsonObject.class); + } catch (Exception e) { + throw new Exception("Invalid vote format: Inner payload is not valid JSON from " + address + ": " + e.getMessage()); + } + + // Validate required fields in inner payload. + if (!votePayload.has("serviceName")) { + throw new Exception("Invalid vote format: Missing required 'serviceName' field in vote payload from " + address); + } + if (!votePayload.has("username")) { + throw new Exception("Invalid vote format: Missing required 'username' field in vote payload from " + address); + } + if (!votePayload.has("address")) { + throw new Exception("Invalid vote format: Missing required 'address' field in vote payload from " + address); + } + if (!votePayload.has("timestamp")) { + throw new Exception("Invalid vote format: Missing required 'timestamp' field in vote payload from " + address); + } + if (!votePayload.has("challenge")) { + throw new Exception("Invalid vote format: Missing required 'challenge' field in vote payload from " + address); + } // Retrieve serviceName from the inner JSON. String serviceNameFromPayload = votePayload.get("serviceName").getAsString(); @@ -355,8 +398,11 @@ public void run() { if (key == null) { key = getTokens().get("default"); if (key == null) { - throw new Exception("Unknown token for service '" + serviceNameFromPayload + "'"); + throw new Exception("Authentication failed: Unknown token for service '" + serviceNameFromPayload + "' from " + address); } + debug("Using default token for service: " + serviceNameFromPayload); + } else { + debug("Using service-specific token for: " + serviceNameFromPayload); } // Debug: log the payload string and its computed HMAC for comparison. @@ -364,7 +410,7 @@ public void run() { // Verify HMAC signature using the payload bytes. if (!hmacEqual(sigBytes, payload.getBytes(StandardCharsets.UTF_8), key)) { - throw new Exception("Signature is not valid (invalid token?) from " + address); + throw new Exception("Authentication failed: Signature verification failed (invalid token?) for service '" + serviceNameFromPayload + "' from " + address); } // Extract vote fields from the inner payload. @@ -373,14 +419,11 @@ public void run() { address1 = votePayload.get("address").getAsString(); timeStamp = votePayload.get("timestamp").getAsString(); - // Check the challenge. - if (!votePayload.has("challenge")) { - throw new Exception("Vote payload missing challenge field from " + address); - } + // Verify the challenge. String receivedChallenge = votePayload.get("challenge").getAsString().trim(); if (!receivedChallenge.equals(challenge.trim())) { throw new Exception( - "Invalid challenge: expected " + challenge + " but got " + receivedChallenge); + "Authentication failed: Invalid challenge (expected '" + challenge + "' but got '" + receivedChallenge + "') from " + address); } } else { String[] fields = voteData.split("\n"); @@ -428,25 +471,32 @@ public void run() { in.close(); socket.close(); } catch (MalformedJsonException ex) { - logWarning("Malformed JSON payload received from: " + address + " - " + ex.getMessage()); + logWarning("Invalid vote format: Malformed JSON payload received from " + address + " - " + ex.getMessage()); debug(ex); } catch (SocketException ex) { if (running) { - logWarning("Protocol error from: " + address + " - " + ex.getLocalizedMessage()); + logWarning("Connection error: Protocol error from " + address + " - " + ex.getLocalizedMessage()); debug(ex); } else { logWarning("Votifier socket closed."); } } catch (BadPaddingException ex) { - logWarning("Unable to decrypt vote record from: " + address + logWarning("Authentication failed: Unable to decrypt V1 vote record from " + address + ". Make sure that your public key matches the one you gave the server list."); debug(ex); } catch (SocketTimeoutException ex) { - logWarning("Socket timeout while waiting for vote payload from: " + address + " - " + ex.getMessage()); + logWarning("Connection timeout: Socket timeout while waiting for vote payload from " + address + " - " + ex.getMessage()); debug(ex); } catch (Exception ex) { - logWarning("Exception caught while receiving a vote notification from: " + address + " - " - + ex.getLocalizedMessage()); + String errorMsg = ex.getMessage(); + if (errorMsg != null && (errorMsg.startsWith("Invalid vote format:") || + errorMsg.startsWith("Authentication failed:"))) { + // These are validation errors with detailed messages, log them as-is + logWarning(errorMsg); + } else { + // Generic exception with less context + logWarning("Error processing vote from " + address + ": " + ex.getLocalizedMessage()); + } debug(ex); } } diff --git a/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/VoteReceiverTest.java b/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/VoteReceiverTest.java index 16981f7..c7efc52 100644 --- a/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/VoteReceiverTest.java +++ b/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/VoteReceiverTest.java @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.BufferedWriter; @@ -111,20 +112,55 @@ public Vote processV1Vote(byte[] encryptedBlock) throws Exception { /** * Process a V2 vote payload in JSON format. + * This method mirrors the validation logic from the main VoteReceiver implementation. */ public Vote processV2Vote(String jsonPayload) throws Exception { Gson gson = new Gson(); JsonObject outer = gson.fromJson(jsonPayload, JsonObject.class); + + // Validate and extract the outer payload and signature fields (matching main implementation). + if (!outer.has("payload")) { + throw new Exception("Invalid vote format: Missing required 'payload' field in outer JSON from test"); + } + if (!outer.has("signature")) { + throw new Exception("Invalid vote format: Missing required 'signature' field in outer JSON from test"); + } + String payload = outer.get("payload").getAsString(); + String sigHash = outer.get("signature").getAsString(); + + // Validate Base64 signature (matching main implementation). + try { + Base64.getDecoder().decode(sigHash); + } catch (IllegalArgumentException e) { + throw new Exception("Invalid vote format: Signature is not valid Base64 from test: " + e.getMessage()); + } + JsonObject inner = gson.fromJson(payload, JsonObject.class); - // Verify challenge. + + // Validate required fields in inner payload (matching main implementation). + if (!inner.has("serviceName")) { + throw new Exception("Invalid vote format: Missing required 'serviceName' field in vote payload from test"); + } + if (!inner.has("username")) { + throw new Exception("Invalid vote format: Missing required 'username' field in vote payload from test"); + } + if (!inner.has("address")) { + throw new Exception("Invalid vote format: Missing required 'address' field in vote payload from test"); + } + if (!inner.has("timestamp")) { + throw new Exception("Invalid vote format: Missing required 'timestamp' field in vote payload from test"); + } if (!inner.has("challenge")) { - throw new Exception("Vote payload missing challenge field."); + throw new Exception("Invalid vote format: Missing required 'challenge' field in vote payload from test"); } + + // Verify challenge. String receivedChallenge = inner.get("challenge").getAsString(); if (!receivedChallenge.equals(getChallenge())) { - throw new Exception("Invalid challenge: expected " + getChallenge() + " but got " + receivedChallenge); + throw new Exception("Authentication failed: Invalid challenge (expected '" + getChallenge() + "' but got '" + receivedChallenge + "') from test"); } + Vote vote = new Vote(); vote.setServiceName(inner.get("serviceName").getAsString()); vote.setUsername(inner.get("username").getAsString()); @@ -319,4 +355,161 @@ public void testConnectHeader() throws Exception { String output = new String(remaining, 0, read, StandardCharsets.US_ASCII); assertEquals(remainingData, output); } + + @Test + public void testV2VoteMissingPayloadField() throws Exception { + // Test V2 vote with missing "payload" field - should throw exception + // Note: Payload field validation occurs before signature validation, + // so we expect the payload error to be thrown first. + TestVoteReceiver tokenReceiver = new TestVoteReceiver("127.0.0.1", 0, testKeyPair) { + @Override + public boolean isUseTokens() { + return true; + } + }; + + JsonObject outer = new JsonObject(); + outer.addProperty("signature", "dummySignature"); + // Missing "payload" field + String jsonPayload = outer.toString(); + + Exception exception = assertThrows(Exception.class, () -> { + tokenReceiver.processV2Vote(jsonPayload); + }); + + assertTrue(exception.getMessage().contains("Missing required 'payload' field"), + "Expected error message about missing payload field, got: " + exception.getMessage()); + tokenReceiver.shutdown(); + } + + @Test + public void testV2VoteMissingSignatureField() throws Exception { + // Test V2 vote with missing "signature" field - should throw exception + TestVoteReceiver tokenReceiver = new TestVoteReceiver("127.0.0.1", 0, testKeyPair) { + @Override + public boolean isUseTokens() { + return true; + } + }; + + JsonObject outer = new JsonObject(); + outer.addProperty("payload", "{}"); + // Missing "signature" field + String jsonPayload = outer.toString(); + + Exception exception = assertThrows(Exception.class, () -> { + tokenReceiver.processV2Vote(jsonPayload); + }); + + assertTrue(exception.getMessage().contains("Missing required 'signature' field"), + "Expected error message about missing signature field, got: " + exception.getMessage()); + tokenReceiver.shutdown(); + } + + @Test + public void testV2VoteMissingUsernameField() throws Exception { + // Test V2 vote with missing "username" field in inner payload + TestVoteReceiver tokenReceiver = new TestVoteReceiver("127.0.0.1", 0, testKeyPair) { + @Override + public boolean isUseTokens() { + return true; + } + }; + + String challenge = "testChallenge"; + JsonObject inner = new JsonObject(); + inner.addProperty("serviceName", "votifier.bencodez.com"); + // Missing "username" field + inner.addProperty("address", "127.0.0.1"); + inner.addProperty("timestamp", "TestTimestamp"); + inner.addProperty("challenge", challenge); + String payload = inner.toString(); + + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(dummyTokenKey); + byte[] signatureBytes = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)); + String signature = Base64.getEncoder().encodeToString(signatureBytes); + + JsonObject outer = new JsonObject(); + outer.addProperty("payload", payload); + outer.addProperty("signature", signature); + String jsonPayload = outer.toString(); + + Exception exception = assertThrows(Exception.class, () -> { + tokenReceiver.processV2Vote(jsonPayload); + }); + + assertTrue(exception.getMessage().contains("Missing required 'username' field"), + "Expected error message about missing username field, got: " + exception.getMessage()); + tokenReceiver.shutdown(); + } + + @Test + public void testV2VoteInvalidChallenge() throws Exception { + // Test V2 vote with incorrect challenge value + TestVoteReceiver tokenReceiver = new TestVoteReceiver("127.0.0.1", 0, testKeyPair) { + @Override + public boolean isUseTokens() { + return true; + } + }; + + JsonObject inner = new JsonObject(); + inner.addProperty("serviceName", "votifier.bencodez.com"); + inner.addProperty("username", "testUser"); + inner.addProperty("address", "127.0.0.1"); + inner.addProperty("timestamp", "TestTimestamp"); + inner.addProperty("challenge", "wrongChallenge"); // Wrong challenge + String payload = inner.toString(); + + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(dummyTokenKey); + byte[] signatureBytes = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)); + String signature = Base64.getEncoder().encodeToString(signatureBytes); + + JsonObject outer = new JsonObject(); + outer.addProperty("payload", payload); + outer.addProperty("signature", signature); + String jsonPayload = outer.toString(); + + Exception exception = assertThrows(Exception.class, () -> { + tokenReceiver.processV2Vote(jsonPayload); + }); + + assertTrue(exception.getMessage().contains("Invalid challenge"), + "Expected error message about invalid challenge, got: " + exception.getMessage()); + tokenReceiver.shutdown(); + } + + @Test + public void testV2VoteInvalidBase64Signature() throws Exception { + // Test V2 vote with invalid base64 signature + TestVoteReceiver tokenReceiver = new TestVoteReceiver("127.0.0.1", 0, testKeyPair) { + @Override + public boolean isUseTokens() { + return true; + } + }; + + JsonObject inner = new JsonObject(); + inner.addProperty("serviceName", "votifier.bencodez.com"); + inner.addProperty("username", "testUser"); + inner.addProperty("address", "127.0.0.1"); + inner.addProperty("timestamp", "TestTimestamp"); + inner.addProperty("challenge", "testChallenge"); + String payload = inner.toString(); + + JsonObject outer = new JsonObject(); + outer.addProperty("payload", payload); + outer.addProperty("signature", "not-valid-base64!!!"); // Invalid base64 + String jsonPayload = outer.toString(); + + Exception exception = assertThrows(Exception.class, () -> { + tokenReceiver.processV2Vote(jsonPayload); + }); + + assertTrue(exception.getMessage().contains("Signature is not valid Base64"), + "Expected error message about invalid base64, got: " + exception.getMessage()); + tokenReceiver.shutdown(); + } }