diff --git a/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteConnectionHandler.java b/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteConnectionHandler.java index 143cbc3..7da1835 100644 --- a/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteConnectionHandler.java +++ b/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteConnectionHandler.java @@ -90,7 +90,7 @@ public Vote handle(Socket socket) { throw new VoteAuthenticationException("Votifier V1 votes are disabled by configuration"); } - VoteRequest request = voteParser.parse(in, version, receiver, address, challenge); + VoteRequest request = voteParser.parse(in, version, receiver, address, challenge, accepted); Vote vote = new Vote(); vote.setServiceName(request.getServiceName()); diff --git a/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteParser.java b/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteParser.java index 8fe76d0..5aced45 100644 --- a/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteParser.java +++ b/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteParser.java @@ -6,10 +6,14 @@ */ package com.vexsoftware.votifier.net; +import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.PushbackInputStream; +import java.net.Socket; +import java.net.SocketTimeoutException; import java.nio.charset.StandardCharsets; import java.security.Key; +import java.util.Arrays; import java.util.Base64; import java.util.Map; @@ -29,6 +33,10 @@ public class VoteParser { private static final Gson GSON = new Gson(); private static final short PROTOCOL_2_MAGIC = (short) 0x733A; + private static final int PROTOCOL_VERSION_PREFIX_BYTES = 2; + private static final int V1_BLOCK_BYTES = 256; + private static final int MAX_V2_PACKET_BYTES = 4 + 0xFFFF; + private static final int V1_COLLISION_GRACE_TIMEOUT_MS = 250; private static final String FIELD_PAYLOAD = "payload"; private static final String FIELD_SIGNATURE = "signature"; @@ -47,19 +55,26 @@ public class VoteParser { * @throws Exception if there is not enough data to determine the protocol */ public VoteProtocolVersion detectVersion(PushbackInputStream in) throws Exception { - byte[] header = new byte[2]; - int bytesRead = in.read(header); + byte[] header = new byte[PROTOCOL_VERSION_PREFIX_BYTES]; + int bytesRead = 0; + while (bytesRead < header.length) { + int read = in.read(header, bytesRead, header.length - bytesRead); + if (read == -1) { + break; + } + bytesRead += read; + } + if (bytesRead < 2) { throw new Exception("Not enough data available to determine vote protocol version."); } + in.unread(header, 0, bytesRead); + if ((char) header[0] == '{') { - in.unread(header, 0, bytesRead); return VoteProtocolVersion.V2; } - in.unread(header, 0, bytesRead); - short magic = (short) (((header[0] & 0xFF) << 8) | (header[1] & 0xFF)); if (magic == PROTOCOL_2_MAGIC) { return VoteProtocolVersion.V2; @@ -81,14 +96,150 @@ public VoteProtocolVersion detectVersion(PushbackInputStream in) throws Exceptio */ public VoteRequest parse(PushbackInputStream in, VoteProtocolVersion version, VoteReceiver receiver, String address, String challenge) throws Exception { + return parse(in, version, receiver, address, challenge, null); + } + + /** + * Parses a vote payload with access to the connection socket so an ambiguous + * V1 collision can receive a short, bounded TCP-fragment grace period. + * + * @param in the input stream + * @param version the detected protocol version + * @param receiver the vote receiver + * @param address remote address string for logging/errors + * @param challenge expected challenge for V2 + * @param socket accepted socket, or null when no timeout control is available + * @return parsed vote request data + * @throws Exception on parse/validation/authentication errors + */ + public VoteRequest parse(PushbackInputStream in, VoteProtocolVersion version, VoteReceiver receiver, String address, + String challenge, Socket socket) throws Exception { if (version == VoteProtocolVersion.V1) { return parseV1(in, receiver, address); } - return parseV2(in, receiver, address, challenge); + + ByteArrayOutputStream voteData = new ByteArrayOutputStream(); + if (!readToSize(in, voteData, PROTOCOL_VERSION_PREFIX_BYTES)) { + throw new InvalidVoteException("Incomplete V2 protocol prefix from " + address); + } + + byte[] prefix = voteData.toByteArray(); + short magic = (short) (((prefix[0] & 0xFF) << 8) | (prefix[1] & 0xFF)); + if (magic == PROTOCOL_2_MAGIC) { + return parseFramedV2(in, voteData, receiver, address, challenge, socket); + } + if ((char) prefix[0] == '{') { + return parseUnframedV2(in, voteData, receiver, address, challenge, socket); + } + + throw new InvalidVoteException("Invalid V2 protocol prefix from " + address); + } + + private VoteRequest parseFramedV2(PushbackInputStream in, ByteArrayOutputStream voteData, VoteReceiver receiver, + String address, String challenge, Socket socket) throws Exception { + if (!readToSize(in, voteData, 4)) { + throw new InvalidVoteException("Incomplete V2 frame header from " + address); + } + + byte[] header = voteData.toByteArray(); + int payloadBytes = ((header[2] & 0xFF) << 8) | (header[3] & 0xFF); + int frameBytes = 4 + payloadBytes; + Exception v1Failure = null; + + // A randomized V1 block can claim a framed length greater than 256. Test + // the complete V1-sized prefix before blocking for the rest of that frame. + if (frameBytes > V1_BLOCK_BYTES && !receiver.isDisableV1()) { + if (!readToSize(in, voteData, V1_BLOCK_BYTES)) { + throw new InvalidVoteException("Incomplete V2 frame from " + address + " (expected " + frameBytes + + " bytes, got " + voteData.size() + ")"); + } + try { + return parseV1Candidate(voteData.toByteArray(), receiver, address); + } catch (Exception ex) { + v1Failure = ex; + } + } + + if (!readToSize(in, voteData, frameBytes)) { + throw new InvalidVoteException("Incomplete V2 frame from " + address + " (expected " + frameBytes + + " bytes, got " + voteData.size() + ")"); + } + + try { + return parseV2(voteData.toByteArray(), receiver, address, challenge); + } catch (Exception v2Failure) { + if (v1Failure != null) { + v2Failure.addSuppressed(v1Failure); + } + return fallbackToBufferedV1OrThrow(in, voteData, receiver, address, v2Failure, socket); + } + } + + private VoteRequest parseUnframedV2(PushbackInputStream in, ByteArrayOutputStream voteData, VoteReceiver receiver, + String address, String challenge, Socket socket) throws Exception { + JsonObjectBoundaryScanner scanner = new JsonObjectBoundaryScanner(); + byte[] prefix = voteData.toByteArray(); + int jsonBoundaryBytes = scanner.scan(prefix, 0, prefix.length); + boolean complete = jsonBoundaryBytes >= 0; + Exception v1Failure = null; + boolean v1Attempted = false; + byte[] buffer = new byte[4096]; + + while (!complete) { + if (!receiver.isDisableV1() && !v1Attempted && voteData.size() == V1_BLOCK_BYTES) { + v1Attempted = true; + try { + return parseV1Candidate(voteData.toByteArray(), receiver, address); + } catch (Exception ex) { + v1Failure = ex; + } + } + + if (voteData.size() >= MAX_V2_PACKET_BYTES) { + InvalidVoteException failure = new InvalidVoteException( + "V2 JSON payload exceeds maximum size from " + address); + if (v1Failure != null) { + failure.addSuppressed(v1Failure); + } + throw failure; + } + + int nextBoundary = voteData.size() < V1_BLOCK_BYTES ? V1_BLOCK_BYTES : MAX_V2_PACKET_BYTES; + int maxRead = Math.min(buffer.length, nextBoundary - voteData.size()); + int read = in.read(buffer, 0, maxRead); + if (read == -1) { + InvalidVoteException failure = new InvalidVoteException("Incomplete V2 JSON payload from " + address); + if (v1Failure != null) { + failure.addSuppressed(v1Failure); + } + throw failure; + } + + int previousSize = voteData.size(); + int completeAt = scanner.scan(buffer, 0, read); + // The entire chunk has already been consumed from the socket. Preserve its + // suffix for a possible fixed-size V1 fallback, while parsing V2 only up + // to the detected JSON boundary. + voteData.write(buffer, 0, read); + if (completeAt >= 0) { + jsonBoundaryBytes = previousSize + completeAt; + } + complete = completeAt >= 0; + } + + try { + byte[] candidate = voteData.toByteArray(); + return parseV2(Arrays.copyOf(candidate, jsonBoundaryBytes), receiver, address, challenge); + } catch (Exception v2Failure) { + if (v1Failure != null) { + v2Failure.addSuppressed(v1Failure); + } + return fallbackToBufferedV1OrThrow(in, voteData, receiver, address, v2Failure, socket); + } } private VoteRequest parseV1(PushbackInputStream in, VoteReceiver receiver, String address) throws Exception { - byte[] block = new byte[256]; + byte[] block = new byte[V1_BLOCK_BYTES]; int totalRead = 0; while (totalRead < block.length) { @@ -99,9 +250,9 @@ private VoteRequest parseV1(PushbackInputStream in, VoteReceiver receiver, Strin totalRead += read; } - if (totalRead != 256) { + if (totalRead != V1_BLOCK_BYTES) { throw new InvalidVoteException("Failed to read complete V1 vote block from " + address - + " (expected 256 bytes, got " + totalRead + ")"); + + " (expected " + V1_BLOCK_BYTES + " bytes, got " + totalRead + ")"); } byte[] decrypted; @@ -141,18 +292,114 @@ private VoteRequest parseV1(PushbackInputStream in, VoteReceiver receiver, Strin return request; } - private VoteRequest parseV2(PushbackInputStream in, VoteReceiver receiver, String address, String challenge) - throws Exception { - ByteArrayOutputStream data = new ByteArrayOutputStream(); - int b; - while ((b = in.read()) != -1) { - data.write(b); - if (in.available() == 0) { - break; + private boolean readToSize(PushbackInputStream in, ByteArrayOutputStream data, int targetBytes) throws Exception { + byte[] buffer = new byte[Math.min(4096, Math.max(1, targetBytes - data.size()))]; + while (data.size() < targetBytes) { + int read = in.read(buffer, 0, Math.min(buffer.length, targetBytes - data.size())); + if (read == -1) { + return false; } + data.write(buffer, 0, read); + } + return true; + } + + private VoteRequest fallbackToBufferedV1OrThrow(PushbackInputStream in, ByteArrayOutputStream voteData, + VoteReceiver receiver, String address, Exception v2Failure, Socket socket) throws Exception { + if (receiver.isDisableV1() || voteData.size() > V1_BLOCK_BYTES) { + throw v2Failure; } - String voteData = data.toString("UTF-8").trim(); + int remaining = V1_BLOCK_BYTES - voteData.size(); + if (remaining > 0) { + if (socket == null) { + if (in.available() < remaining || !readToSize(in, voteData, V1_BLOCK_BYTES)) { + throw v2Failure; + } + } else if (!readV1CollisionRemainder(in, voteData, socket, v2Failure)) { + throw v2Failure; + } + } + + try { + return parseV1Candidate(voteData.toByteArray(), receiver, address); + } catch (Exception v1Failure) { + v2Failure.addSuppressed(v1Failure); + throw v2Failure; + } + } + + private boolean readV1CollisionRemainder(PushbackInputStream in, ByteArrayOutputStream voteData, Socket socket, + Exception v2Failure) throws Exception { + int previousTimeout = socket.getSoTimeout(); + long deadlineNanos = System.nanoTime() + V1_COLLISION_GRACE_TIMEOUT_MS * 1_000_000L; + byte[] buffer = new byte[V1_BLOCK_BYTES - voteData.size()]; + try { + while (voteData.size() < V1_BLOCK_BYTES) { + long remainingNanos = deadlineNanos - System.nanoTime(); + if (remainingNanos <= 0) { + return false; + } + + int remainingMillis = (int) Math.max(1, (remainingNanos + 999_999L) / 1_000_000L); + int readTimeout = previousTimeout <= 0 ? remainingMillis : Math.min(previousTimeout, remainingMillis); + socket.setSoTimeout(readTimeout); + + int read = in.read(buffer, 0, Math.min(buffer.length, V1_BLOCK_BYTES - voteData.size())); + if (read == -1) { + return false; + } + voteData.write(buffer, 0, read); + } + return true; + } catch (SocketTimeoutException ex) { + v2Failure.addSuppressed(ex); + return false; + } finally { + socket.setSoTimeout(previousTimeout); + } + } + + private VoteRequest parseV1Candidate(byte[] candidate, VoteReceiver receiver, String address) throws Exception { + return parseV1(new PushbackInputStream(new ByteArrayInputStream(candidate), V1_BLOCK_BYTES), receiver, address); + } + + private static class JsonObjectBoundaryScanner { + private int depth; + private boolean escaped; + private boolean inString; + private boolean started; + + private int scan(byte[] data, int offset, int length) { + for (int i = offset; i < offset + length; i++) { + char current = (char) (data[i] & 0xFF); + if (inString) { + if (escaped) { + escaped = false; + } else if (current == '\\') { + escaped = true; + } else if (current == '"') { + inString = false; + } + continue; + } + + if (current == '"') { + inString = true; + } else if (current == '{') { + started = true; + depth++; + } else if (current == '}' && started && --depth == 0) { + return i - offset + 1; + } + } + return -1; + } + } + + private VoteRequest parseV2(byte[] data, VoteReceiver receiver, String address, String challenge) + throws Exception { + String voteData = new String(data, StandardCharsets.UTF_8).trim(); receiver.debug("Received raw V2 vote payload: [" + voteData + "]"); int firstBrace = voteData.indexOf('{'); @@ -289,4 +536,4 @@ private boolean hmacEqual(byte[] providedSig, byte[] data, Key key) throws Excep } return diff == 0; } -} \ No newline at end of file +} 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 97eeade..2d68e4e 100644 --- a/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/VoteReceiverTest.java +++ b/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/VoteReceiverTest.java @@ -10,6 +10,8 @@ import java.io.ByteArrayOutputStream; import java.io.OutputStreamWriter; import java.io.PushbackInputStream; +import java.net.ServerSocket; +import java.net.Socket; import java.nio.charset.StandardCharsets; import java.security.Key; import java.security.KeyPair; @@ -76,6 +78,7 @@ public void tearDown() { private static class TestVoteReceiver extends VoteReceiver { private final String testChallenge = "testChallenge"; + private boolean disableV1; public TestVoteReceiver(String host, int port) throws Exception { super(host, port); @@ -86,6 +89,15 @@ public boolean isUseTokens() { return false; } + @Override + public boolean isDisableV1() { + return disableV1; + } + + public void setDisableV1(boolean disableV1) { + this.disableV1 = disableV1; + } + @Override public void logWarning(String warn) { } @@ -148,10 +160,9 @@ public ThrottleConfig getThrottleConfig() { @Test public void testDetectV1VoteProtocol() throws Exception { - String voteMsg = "VOTE\nvotifier.bencodez.com\ntestUser\n127.0.0.1\nTestTimestamp\n"; - Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding"); - cipher.init(Cipher.ENCRYPT_MODE, testKeyPair.getPublic()); - byte[] encrypted = cipher.doFinal(voteMsg.getBytes(StandardCharsets.UTF_8)); + byte[] encrypted = new byte[256]; + encrypted[0] = 1; + encrypted[1] = 2; PushbackInputStream in = new PushbackInputStream(new ByteArrayInputStream(encrypted), 512); VoteProtocolVersion version = parser.detectVersion(in); @@ -187,26 +198,217 @@ public void testDetectV2VoteProtocol() throws Exception { } @Test - public void testParseV2Vote() throws Exception { - JsonObject inner = new JsonObject(); - inner.addProperty("serviceName", "votifier.bencodez.com"); - inner.addProperty("username", "testUserV2"); - inner.addProperty("address", "127.0.0.1"); - inner.addProperty("timestamp", "TestTimestampV2"); - inner.addProperty("challenge", "testChallenge"); - String payload = inner.toString(); + public void testDetectFramedV2VoteProtocol() throws Exception { + byte[] jsonPayload = " \r\n{\"payload\":\"{}\",\"signature\":\"dGVzdA==\"}".getBytes(StandardCharsets.UTF_8); - Mac mac = Mac.getInstance("HmacSHA256"); - mac.init(dummyTokenKey); - byte[] signatureBytes = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)); - String signature = Base64.getEncoder().encodeToString(signatureBytes); + PushbackInputStream in = new PushbackInputStream( + new ByteArrayInputStream(frameV2Payload(jsonPayload)), 512); - JsonObject outer = new JsonObject(); - outer.addProperty("payload", payload); - outer.addProperty("signature", signature); + VoteProtocolVersion version = parser.detectVersion(in); + assertEquals(VoteProtocolVersion.V2, version); + } + + @Test + public void testCompleteInvalidFramedV2DoesNotReadPastDeclaredLength() throws Exception { + byte[] jsonPayload = "{\"signature\":\"dummySignature\"}".getBytes(StandardCharsets.UTF_8); + byte[] framedPayload = frameV2Payload(jsonPayload); + ByteArrayInputStream boundaryChecked = new ByteArrayInputStream(framedPayload) { + @Override + public synchronized int read() { + if (pos >= count) { + throw new AssertionError("Parser read past the declared V2 frame boundary"); + } + return super.read(); + } + + @Override + public synchronized int read(byte[] bytes, int offset, int length) { + if (pos >= count) { + throw new AssertionError("Parser read past the declared V2 frame boundary"); + } + return super.read(bytes, offset, length); + } + }; + PushbackInputStream in = new PushbackInputStream(boundaryChecked, 512); + + VoteProtocolVersion version = parser.detectVersion(in); + assertThrows(InvalidVoteException.class, + () -> parser.parse(in, version, receiver, "test-address", receiver.getChallenge())); + } + + @Test + public void testInvalidFramedV2CollisionGraceIsBounded() throws Exception { + byte[] framedPayload = frameV2Payload( + "{\"signature\":\"dummySignature\"}".getBytes(StandardCharsets.UTF_8)); + + try (ServerSocket server = new ServerSocket(0); + Socket client = new Socket("127.0.0.1", server.getLocalPort()); + Socket accepted = server.accept(); + PushbackInputStream in = new PushbackInputStream(accepted.getInputStream(), 512)) { + accepted.setSoTimeout(5000); + client.getOutputStream().write(framedPayload); + client.getOutputStream().flush(); + Thread trickleWriter = new Thread(() -> { + try { + for (int i = 0; i < 3; i++) { + Thread.sleep(100); + client.getOutputStream().write(1); + client.getOutputStream().flush(); + } + } catch (Exception ex) { + throw new RuntimeException(ex); + } + }); + trickleWriter.start(); + + VoteProtocolVersion version = parser.detectVersion(in); + long startedAt = System.nanoTime(); + assertThrows(InvalidVoteException.class, () -> parser.parse(in, version, receiver, "test-address", + receiver.getChallenge(), accepted)); + long elapsedMillis = (System.nanoTime() - startedAt) / 1_000_000; + trickleWriter.join(); + + assertTrue(elapsedMillis < 1000, + "Invalid framed V2 packet exceeded bounded collision grace: " + elapsedMillis + "ms"); + } + } + + @Test + public void testV1MagicPrefixCollisionAttemptsV1Fallback() throws Exception { + byte[] v1Block = new byte[256]; + v1Block[0] = 0x73; + v1Block[1] = 0x3A; + v1Block[2] = 0; + v1Block[3] = 64; + v1Block[4] = '{'; + v1Block[5] = '}'; + + PushbackInputStream in = new PushbackInputStream(new ByteArrayInputStream(v1Block), 512); + + VoteProtocolVersion version = parser.detectVersion(in); + assertEquals(VoteProtocolVersion.V2, version); + Exception failure = assertThrows(Exception.class, + () -> parser.parse(in, version, receiver, "test-address", receiver.getChallenge())); + assertEquals(1, failure.getSuppressed().length); + } + + @Test + public void testFragmentedV1MagicPrefixCollisionAttemptsV1Fallback() throws Exception { + byte[] v1Block = new byte[256]; + v1Block[0] = 0x73; + v1Block[1] = 0x3A; + v1Block[2] = 0; + v1Block[3] = 64; + v1Block[4] = '{'; + v1Block[5] = '}'; + + ByteArrayInputStream fragmented = new ByteArrayInputStream(v1Block) { + @Override + public synchronized int available() { + return pos <= 2 ? 0 : super.available(); + } + }; + PushbackInputStream in = new PushbackInputStream(fragmented, 512); + + VoteProtocolVersion version = parser.detectVersion(in); + assertEquals(VoteProtocolVersion.V2, version); + Exception failure = assertThrows(Exception.class, + () -> parser.parse(in, version, receiver, "test-address", receiver.getChallenge())); + assertEquals(1, failure.getSuppressed().length); + } + @Test + public void testDelayedV1MagicCollisionRemainderUsesBoundedGraceRead() throws Exception { + byte[] v1Block = new byte[256]; + v1Block[0] = 0x73; + v1Block[1] = 0x3A; + v1Block[2] = 0; + v1Block[3] = 64; + v1Block[4] = '{'; + v1Block[5] = '}'; + + try (ServerSocket server = new ServerSocket(0); + Socket client = new Socket("127.0.0.1", server.getLocalPort()); + Socket accepted = server.accept(); + PushbackInputStream in = new PushbackInputStream(accepted.getInputStream(), 512)) { + client.getOutputStream().write(v1Block, 0, 68); + client.getOutputStream().flush(); + + Thread remainderWriter = new Thread(() -> { + try { + Thread.sleep(50); + client.getOutputStream().write(v1Block, 68, v1Block.length - 68); + client.getOutputStream().flush(); + } catch (Exception ex) { + throw new RuntimeException(ex); + } + }); + remainderWriter.start(); + + VoteProtocolVersion version = parser.detectVersion(in); + Exception failure = assertThrows(Exception.class, () -> parser.parse(in, version, receiver, + "test-address", receiver.getChallenge(), accepted)); + remainderWriter.join(); + assertEquals(1, failure.getSuppressed().length); + } + } + + @Test + public void testV1MagicPrefixCollisionDoesNotFallbackWhenV1IsDisabled() throws Exception { + byte[] v1Block = new byte[256]; + v1Block[0] = 0x73; + v1Block[1] = 0x3A; + v1Block[2] = 0; + v1Block[3] = 64; + v1Block[4] = '{'; + v1Block[5] = '}'; + receiver.setDisableV1(true); + + PushbackInputStream in = new PushbackInputStream(new ByteArrayInputStream(v1Block), 512); + + VoteProtocolVersion version = parser.detectVersion(in); + Exception failure = assertThrows(Exception.class, + () -> parser.parse(in, version, receiver, "test-address", receiver.getChallenge())); + assertEquals(0, failure.getSuppressed().length); + } + + @Test + public void testV1JsonPrefixCollisionAttemptsV1Fallback() throws Exception { + byte[] v1Block = new byte[256]; + v1Block[0] = '{'; + v1Block[1] = '}'; + + PushbackInputStream in = new PushbackInputStream(new ByteArrayInputStream(v1Block), 512); + + VoteProtocolVersion version = parser.detectVersion(in); + assertEquals(VoteProtocolVersion.V2, version); + Exception failure = assertThrows(Exception.class, + () -> parser.parse(in, version, receiver, "test-address", receiver.getChallenge())); + assertEquals(1, failure.getSuppressed().length); + } + + @Test + public void testV1JsonPrefixBulkReadPreservesSuffixForFallback() throws Exception { + byte[] v1Block = new byte[256]; + v1Block[0] = '{'; + v1Block[1] = '"'; + v1Block[2] = 'a'; + v1Block[3] = '"'; + v1Block[4] = '}'; + + PushbackInputStream in = new PushbackInputStream(new ByteArrayInputStream(v1Block), 512); + + VoteProtocolVersion version = parser.detectVersion(in); + assertEquals(VoteProtocolVersion.V2, version); + Exception failure = assertThrows(Exception.class, + () -> parser.parse(in, version, receiver, "test-address", receiver.getChallenge())); + assertEquals(1, failure.getSuppressed().length); + } + + @Test + public void testParseV2Vote() throws Exception { PushbackInputStream in = new PushbackInputStream( - new ByteArrayInputStream(outer.toString().getBytes(StandardCharsets.UTF_8)), 512); + new ByteArrayInputStream(createSignedV2Payload("testUserV2")), 512); VoteRequest request = parser.parse(in, VoteProtocolVersion.V2, receiver, "test-address", receiver.getChallenge()); @@ -217,6 +419,23 @@ public void testParseV2Vote() throws Exception { assertEquals("TestTimestampV2", request.getTimeStamp()); } + @Test + public void testParseByteFragmentedFramedV2Vote() throws Exception { + byte[] framedPayload = frameV2Payload(createSignedV2Payload("fragmentedUser")); + ByteArrayInputStream fragmented = new ByteArrayInputStream(framedPayload) { + @Override + public synchronized int read(byte[] bytes, int offset, int length) { + return super.read(bytes, offset, Math.min(1, length)); + } + }; + PushbackInputStream in = new PushbackInputStream(fragmented, 512); + + VoteProtocolVersion version = parser.detectVersion(in); + VoteRequest request = parser.parse(in, version, receiver, "test-address", receiver.getChallenge()); + + assertEquals("fragmentedUser", request.getUsername()); + } + @Test public void testProxyV1Header() throws Exception { String proxyHeader = "PROXY TCP4 192.168.1.1 192.168.1.2 1234 80\r\n"; @@ -420,4 +639,34 @@ public void testBuildVoteFromParsedRequest() throws Exception { assertEquals("TestTimestamp", vote.getTimeStamp()); assertEquals("192.168.1.1", vote.getSourceAddress()); } -} \ No newline at end of file + + private byte[] createSignedV2Payload(String username) throws Exception { + JsonObject inner = new JsonObject(); + inner.addProperty("serviceName", "votifier.bencodez.com"); + inner.addProperty("username", username); + inner.addProperty("address", "127.0.0.1"); + inner.addProperty("timestamp", "TestTimestampV2"); + inner.addProperty("challenge", "testChallenge"); + String payload = inner.toString(); + + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(dummyTokenKey); + String signature = Base64.getEncoder() + .encodeToString(mac.doFinal(payload.getBytes(StandardCharsets.UTF_8))); + + JsonObject outer = new JsonObject(); + outer.addProperty("payload", payload); + outer.addProperty("signature", signature); + return outer.toString().getBytes(StandardCharsets.UTF_8); + } + + private byte[] frameV2Payload(byte[] jsonPayload) throws Exception { + ByteArrayOutputStream framedPayload = new ByteArrayOutputStream(); + framedPayload.write(0x73); + framedPayload.write(0x3A); + framedPayload.write((jsonPayload.length >>> 8) & 0xFF); + framedPayload.write(jsonPayload.length & 0xFF); + framedPayload.write(jsonPayload); + return framedPayload.toByteArray(); + } +}