mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-10 16:02:25 +01:00
Fix protocol framing, TLS, and connection negotiation
This commit is contained in:
1 parent
75c11a92d9
commit
c04c35b5cd
11 files changed
+467
-199
No files matched your search
@@ -7,17 +7,20 @@ import java.io.InputStream
|
||||
import java.nio.ByteBuffer
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.FutureTask
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.net.ssl.SSLContext
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Test
|
||||
|
||||
class ConnectionLifecycleTest {
|
||||
private val version = message(1, byteArrayOf(0, 1, 0, 3))
|
||||
|
||||
private fun message(id: Int, body: ByteArray = byteArrayOf()) = Frames.build(
|
||||
Frames.CHANNEL_CONTROL, false, byteArrayOf(0, id.toByte()) + body,
|
||||
Frames.CHANNEL_CONTROL, false, byteArrayOf((id ushr 8).toByte(), id.toByte()) + body,
|
||||
)
|
||||
|
||||
private fun hello(): ByteArray {
|
||||
@@ -41,13 +44,13 @@ class ConnectionLifecycleTest {
|
||||
private fun runner(input: InputStream) = GearslipRunner(input, ByteArrayOutputStream(), ::identity)
|
||||
|
||||
@Test fun `rejected authentication ignores subsequent success and service discovery in the same read`() {
|
||||
for (status in listOf(-2, -3)) {
|
||||
for (status in listOf(-2, -3, -23, -24)) {
|
||||
runner(ByteArrayInputStream(
|
||||
version + hello() + message(4, Protobuf.varintField(1, status.toLong())) +
|
||||
message(4, Protobuf.varintField(1, 0)) + message(6) + version,
|
||||
)).run()
|
||||
assertEquals(
|
||||
if (status == -2) SessionReport.Category.CERTIFICATE else SessionReport.Category.AUTH,
|
||||
if (status == -3) SessionReport.Category.AUTH else SessionReport.Category.CERTIFICATE,
|
||||
SessionReport.category,
|
||||
)
|
||||
assertEquals(SessionStatus.Phase.FAILED, SessionStatus.state.value.phase)
|
||||
@@ -56,14 +59,70 @@ class ConnectionLifecycleTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `protocol error terminates setup and ignores later messages`() {
|
||||
for (error in listOf(255, 65535)) {
|
||||
val runner = runner(ByteArrayInputStream(version + message(error) + version + message(6)))
|
||||
runner.run()
|
||||
assertFalse(getField(runner, "running") as Boolean)
|
||||
assertEquals(SessionReport.Category.UNKNOWN, SessionReport.category)
|
||||
assertEquals("Protocol error", SessionStatus.state.value.headline)
|
||||
assertEquals(SessionReport.Stage.VERSIONS, SessionReport.stage)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `head unit can end the session before authentication`() {
|
||||
val output = ByteArrayOutputStream()
|
||||
val runner = GearslipRunner(ByteArrayInputStream(version + message(15, Protobuf.varintField(1, 7))), output, ::identity)
|
||||
runner.run()
|
||||
val parser = Frames.Parser()
|
||||
parser.append(output.toByteArray(), output.size())
|
||||
assertEquals(2, requireNotNull(parser.next()).payload[1].toInt())
|
||||
val reply = requireNotNull(parser.next())
|
||||
assertFalse(reply.encrypted)
|
||||
assertArrayEquals(byteArrayOf(0, 16), reply.payload)
|
||||
assertEquals(SessionReport.Category.BYEBYE, SessionReport.category)
|
||||
assertEquals(SessionStatus.Phase.DISCONNECTED, SessionStatus.state.value.phase)
|
||||
}
|
||||
|
||||
@Test fun `TLS failure ignores later protocol messages`() {
|
||||
val fatalAlert = message(3, byteArrayOf(21, 3, 3, 0, 2, 2, 42))
|
||||
runner(ByteArrayInputStream(version + hello() + fatalAlert + version + message(6))).run()
|
||||
assertEquals(SessionReport.Category.TLS, SessionReport.category)
|
||||
assertEquals("Head unit rejected the certificate", SessionStatus.state.value.headline)
|
||||
assertEquals("Secure connection failed", SessionStatus.state.value.headline)
|
||||
assertTrue(SessionReport.stage < SessionReport.Stage.ACCEPTED)
|
||||
}
|
||||
|
||||
@Test fun `local TLS failure ends the session without waiting for peer EOF`() {
|
||||
val input = CountingInput(version + hello())
|
||||
val runner = GearslipRunner(input, ByteArrayOutputStream(), { error("identity unavailable") })
|
||||
runner.run()
|
||||
assertEquals(1, input.reads)
|
||||
assertEquals(SessionReport.Category.TLS, SessionReport.category)
|
||||
assertEquals("Secure connection failed", SessionStatus.state.value.headline)
|
||||
assertTrue((getField(runner, "writer") as ExecutorService).isShutdown)
|
||||
}
|
||||
|
||||
@Test fun `unsupported version sends rejection and ends without waiting for peer EOF`() {
|
||||
val input = CountingInput(message(1, byteArrayOf(0, 2, 0, 0)))
|
||||
val output = ByteArrayOutputStream()
|
||||
val runner = GearslipRunner(input, output, ::identity)
|
||||
runner.run()
|
||||
assertEquals(1, input.reads)
|
||||
val parser = Frames.Parser()
|
||||
parser.append(output.toByteArray(), output.size())
|
||||
val response = requireNotNull(parser.next())
|
||||
assertArrayEquals(byteArrayOf(0, 2) + ProtocolVersion.respond(2, 0).bytes, response.payload)
|
||||
assertEquals("Unsupported protocol", SessionStatus.state.value.headline)
|
||||
}
|
||||
|
||||
private class CountingInput(bytes: ByteArray) : ByteArrayInputStream(bytes) {
|
||||
var reads = 0
|
||||
override fun read(bytes: ByteArray, offset: Int, length: Int): Int {
|
||||
reads++
|
||||
return super.read(bytes, offset, length)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `authentication success before TLS completes is ignored`() {
|
||||
runner(ByteArrayInputStream(version + message(4, Protobuf.varintField(1, 0)))).run()
|
||||
assertEquals(SessionReport.Category.TLS, SessionReport.category)
|
||||
@@ -133,4 +192,41 @@ class ConnectionLifecycleTest {
|
||||
assertTrue((field.get(runner) as ExecutorService).isShutdown)
|
||||
}
|
||||
}
|
||||
@Test fun `ping echoes the full timestamp and optional payload`() {
|
||||
for (payload in listOf(null, byteArrayOf(), byteArrayOf(0, -1, 42))) {
|
||||
val output = ByteArrayOutputStream()
|
||||
val runner = GearslipRunner(ByteArrayInputStream(byteArrayOf()), output, ::identity)
|
||||
val identity = identity()
|
||||
val tls = FutureTask { PhoneTls(identity.keyStore, identity.password) }.apply { run() }
|
||||
setField(runner, "identityTask", tls)
|
||||
val timestamp = 0x1234_5678_9abcL
|
||||
val request = Protobuf.varintField(1, timestamp) + Protobuf.varintField(2, 1) +
|
||||
(payload?.let { Protobuf.bytesField(3, it) } ?: byteArrayOf())
|
||||
val ping = GearslipRunner::class.java.getDeclaredMethod("onPingRequest", ByteArray::class.java)
|
||||
.apply { isAccessible = true }
|
||||
try {
|
||||
ping.invoke(runner, request)
|
||||
val writer = getField(runner, "writer") as ExecutorService
|
||||
writer.shutdown()
|
||||
assertTrue(writer.awaitTermination(2, TimeUnit.SECONDS))
|
||||
val parser = Frames.Parser()
|
||||
parser.append(output.toByteArray(), output.size())
|
||||
val response = requireNotNull(parser.next()).payload
|
||||
assertEquals(12, response[1].toInt())
|
||||
val fields = Wire.fields(response.copyOfRange(2, response.size))
|
||||
assertEquals(timestamp, Wire.varint(fields, 1))
|
||||
assertArrayEquals(payload, Wire.bytes(fields, 2))
|
||||
} finally {
|
||||
runner.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getField(runner: GearslipRunner, name: String): Any? =
|
||||
GearslipRunner::class.java.getDeclaredField(name).apply { isAccessible = true }.get(runner)
|
||||
|
||||
private fun setField(runner: GearslipRunner, name: String, value: Any) {
|
||||
GearslipRunner::class.java.getDeclaredField(name).apply { isAccessible = true }.set(runner, value)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package app.seb3thehacker.gearslip
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
@@ -42,6 +43,14 @@ class CryptoTest {
|
||||
assertTrue(TlsSelfTest.run(identity))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `TLS rejects a head unit that does not present a client certificate`() {
|
||||
val generated = SelfSignedCert.generate()
|
||||
val identity = CertProvider.Identity(generated.keyStore, generated.certificate,
|
||||
SelfSignedCert.PASSWORD, "test")
|
||||
assertFalse(TlsSelfTest.run(identity, withClientCertificate = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `frames round-trip through the parser`() {
|
||||
val payload = ByteArray(300) { it.toByte() }
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package app.seb3thehacker.gearslip
|
||||
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.IOException
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class FramingTest {
|
||||
@Test fun `fragmentation survives every byte arriving separately`() {
|
||||
val message = ByteArray(40_000) { it.toByte() }
|
||||
val output = ByteArrayOutputStream()
|
||||
assertEquals(3, Frames.writeMessage(output, 5, false, Frames.MESSAGE_SPECIFIC, message) { error("plaintext") })
|
||||
val parser = Frames.Parser()
|
||||
val assembler = Frames.Assembler()
|
||||
var received: ByteArray? = null
|
||||
for (byte in output.toByteArray()) {
|
||||
parser.append(byteArrayOf(byte), 1)
|
||||
parser.next()?.let { received = assembler.offer(it, it.payload) }
|
||||
}
|
||||
assertArrayEquals(message, received)
|
||||
}
|
||||
|
||||
@Test fun `encrypted fragments count plaintext length and encrypt each chunk`() {
|
||||
val output = ByteArrayOutputStream()
|
||||
val message = ByteArray(20_000) { 42 }
|
||||
Frames.writeMessage(output, 1, true, Frames.MESSAGE_SPECIFIC, message) { byteArrayOf(99) + it }
|
||||
val parser = Frames.Parser()
|
||||
parser.append(output.toByteArray(), output.size())
|
||||
val first = parser.next()!!
|
||||
val last = parser.next()!!
|
||||
assertEquals(20_000, first.totalSize)
|
||||
assertEquals(16_121, first.payload.size)
|
||||
assertEquals(Frames.TYPE_LAST, last.frameType)
|
||||
val assembler = Frames.Assembler()
|
||||
assertNull(assembler.offer(first, first.payload.drop(1).toByteArray()))
|
||||
assertArrayEquals(message, assembler.offer(last, last.payload.drop(1).toByteArray()))
|
||||
}
|
||||
|
||||
@Test fun `fragment budget includes the short or extended header`() {
|
||||
for (size in listOf(16_124, 16_125, 32_245)) {
|
||||
val output = ByteArrayOutputStream()
|
||||
Frames.writeMessage(output, 1, false, Frames.MESSAGE_SPECIFIC, ByteArray(size)) { error("plaintext") }
|
||||
val parser = Frames.Parser()
|
||||
parser.append(output.toByteArray(), output.size())
|
||||
val first = parser.next()!!
|
||||
if (size == 16_124) {
|
||||
assertEquals(Frames.TYPE_BULK, first.frameType)
|
||||
assertEquals(16_124, first.payload.size)
|
||||
} else {
|
||||
assertEquals(Frames.TYPE_FIRST, first.frameType)
|
||||
assertEquals(size, first.totalSize)
|
||||
assertEquals(16_120, first.payload.size)
|
||||
val next = parser.next()!!
|
||||
if (size == 32_245) {
|
||||
assertEquals(Frames.TYPE_MIDDLE, next.frameType)
|
||||
assertEquals(16_124, next.payload.size)
|
||||
val last = parser.next()!!
|
||||
assertEquals(Frames.TYPE_LAST, last.frameType)
|
||||
assertEquals(1, last.payload.size)
|
||||
} else {
|
||||
assertEquals(Frames.TYPE_LAST, next.frameType)
|
||||
assertEquals(5, next.payload.size)
|
||||
}
|
||||
}
|
||||
assertNull(parser.next())
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `channel control messages cannot be fragmented`() {
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
Frames.writeMessage(ByteArrayOutputStream(), 1, false, Frames.MESSAGE_CONTROL, ByteArray(16_125)) { it }
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `malformed and mismatched fragment runs are rejected`() {
|
||||
val first = Frames.Frame(1, Frames.TYPE_FIRST, true, 0, byteArrayOf(), 4)
|
||||
val last = Frames.Frame(1, Frames.TYPE_LAST, true, 0, byteArrayOf())
|
||||
assertThrows(IOException::class.java) { Frames.Assembler().offer(last, byteArrayOf(1)) }
|
||||
val assembler = Frames.Assembler()
|
||||
assembler.offer(first, byteArrayOf(1, 2))
|
||||
assertThrows(IOException::class.java) { assembler.offer(last, byteArrayOf(3)) }
|
||||
assembler.offer(first, byteArrayOf(1, 2))
|
||||
assertThrows(IOException::class.java) { assembler.offer(last, byteArrayOf(3, 4, 5)) }
|
||||
assembler.offer(first, byteArrayOf(1, 2))
|
||||
assertThrows(IOException::class.java) { assembler.offer(first, byteArrayOf(1, 2)) }
|
||||
}
|
||||
|
||||
@Test fun `oversized declared message is rejected before allocation`() {
|
||||
val parser = Frames.Parser()
|
||||
val frame = byteArrayOf(1, 1, 0, 1, 0x7f, -1, -1, -1, 0)
|
||||
parser.append(frame, frame.size)
|
||||
assertThrows(IOException::class.java) { parser.next() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package app.seb3thehacker.gearslip
|
||||
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class ProtobufTest {
|
||||
@Test fun `malformed suffix invalidates the whole message`() {
|
||||
for (suffix in listOf(byteArrayOf(0), byteArrayOf(18, -1), byteArrayOf(13, 1), byteArrayOf(17, 1))) {
|
||||
assertTrue(Wire.fields(byteArrayOf(8, 2) + suffix).isEmpty())
|
||||
assertNull(Protobuf.readInt32Field(byteArrayOf(8, 2) + suffix, 1))
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `lengths cannot wrap when narrowed to int`() {
|
||||
val huge = byteArrayOf(18) + Protobuf.varint(0x1_0000_0001) + byteArrayOf(42)
|
||||
assertTrue(Wire.fields(huge).isEmpty())
|
||||
}
|
||||
|
||||
@Test fun `last singular field wins and negative int32 keeps its sign`() {
|
||||
assertEquals(2, Protobuf.readInt32Field(byteArrayOf(8, 1, 8, 2), 1))
|
||||
assertEquals(-3, Protobuf.readInt32Field(Protobuf.varintField(1, -3), 1))
|
||||
assertEquals(8_803_621_255L, Protobuf.readInt64Field(Protobuf.varintField(1, 8_803_621_255L), 1))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package app.seb3thehacker.gearslip
|
||||
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class ProtocolVersionTest {
|
||||
@Test fun `advertises supported version to older and newer 1x peers`() {
|
||||
assertArrayEquals(byteArrayOf(0, 1, 0, 7, 0, 0), ProtocolVersion.respond(1, 3).bytes)
|
||||
assertArrayEquals(byteArrayOf(0, 1, 0, 7, 0, 0), ProtocolVersion.respond(1, 7).bytes)
|
||||
assertArrayEquals(byteArrayOf(0, 1, 0, 7, 0, 0), ProtocolVersion.respond(1, 9).bytes)
|
||||
}
|
||||
|
||||
@Test fun `unsupported major is never reported as a successful downgrade`() {
|
||||
val response = ProtocolVersion.respond(4, 3)
|
||||
assertFalse(response.supported)
|
||||
assertArrayEquals(byteArrayOf(0, 1, 0, 7, -1, -1), response.bytes)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user