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
@@ -1,20 +1,10 @@
|
|||||||
package app.seb3thehacker.gearslip
|
package app.seb3thehacker.gearslip
|
||||||
|
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.io.IOException
|
||||||
|
import java.io.OutputStream
|
||||||
|
|
||||||
/**
|
/** Android Auto framing with plaintext fragmentation before per-frame encryption. */
|
||||||
* Android Auto frame layout, transcribed from aasdk.
|
|
||||||
*
|
|
||||||
* FrameHeader::getData (src/Messenger/FrameHeader.cpp:47-55),
|
|
||||||
* FrameSize::getData (src/Messenger/FrameSize.cpp:49-62) and
|
|
||||||
* MessageOutStream::compoundFrame (src/Messenger/MessageOutStream.cpp:100-120):
|
|
||||||
*
|
|
||||||
* byte 0 channel id
|
|
||||||
* byte 1 frameType | encryption | messageType
|
|
||||||
* bytes 2-3 uint16 BE payload length of THIS frame
|
|
||||||
* bytes 4-7 uint32 BE total message length - present ONLY when frameType == FIRST
|
|
||||||
* payload uint16 BE message id, then the body
|
|
||||||
*/
|
|
||||||
object Frames {
|
object Frames {
|
||||||
|
|
||||||
const val CHANNEL_CONTROL = 0
|
const val CHANNEL_CONTROL = 0
|
||||||
@@ -36,8 +26,11 @@ object Frames {
|
|||||||
const val MESSAGE_SPECIFIC = 0
|
const val MESSAGE_SPECIFIC = 0
|
||||||
const val MESSAGE_CONTROL = 1 shl 2
|
const val MESSAGE_CONTROL = 1 shl 2
|
||||||
|
|
||||||
/** aasdk splits at this size (MessageOutStream.hpp:62). Nothing the spike sends is close. */
|
// Android Auto's default fragment budget includes the framing header. TLS adds overhead.
|
||||||
const val MAX_FRAME_PAYLOAD = 0x4000
|
private const val FRAGMENT_SIZE = 0x3f00
|
||||||
|
private const val HEADER_SIZE = 4
|
||||||
|
private const val FIRST_HEADER_SIZE = 8
|
||||||
|
const val MAX_FRAME_PAYLOAD = FRAGMENT_SIZE - HEADER_SIZE
|
||||||
|
|
||||||
class Frame(
|
class Frame(
|
||||||
val channel: Int,
|
val channel: Int,
|
||||||
@@ -45,6 +38,7 @@ object Frames {
|
|||||||
val encrypted: Boolean,
|
val encrypted: Boolean,
|
||||||
val messageType: Int,
|
val messageType: Int,
|
||||||
val payload: ByteArray,
|
val payload: ByteArray,
|
||||||
|
val totalSize: Int? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -60,6 +54,11 @@ object Frames {
|
|||||||
frameType: Int = TYPE_BULK,
|
frameType: Int = TYPE_BULK,
|
||||||
totalSize: Int? = null,
|
totalSize: Int? = null,
|
||||||
): ByteArray {
|
): ByteArray {
|
||||||
|
require(channel in 0..255 && payload.size <= 0xffff)
|
||||||
|
require(frameType in TYPE_MIDDLE..TYPE_BULK)
|
||||||
|
require(messageType == MESSAGE_SPECIFIC || messageType == MESSAGE_CONTROL)
|
||||||
|
require((frameType == TYPE_FIRST) == (totalSize != null))
|
||||||
|
require(totalSize == null || totalSize in 2..MAX_MESSAGE_SIZE)
|
||||||
val flags = frameType or messageType or (if (encrypted) ENCRYPTED else 0)
|
val flags = frameType or messageType or (if (encrypted) ENCRYPTED else 0)
|
||||||
val header = byteArrayOf(
|
val header = byteArrayOf(
|
||||||
channel.toByte(),
|
channel.toByte(),
|
||||||
@@ -67,7 +66,7 @@ object Frames {
|
|||||||
((payload.size shr 8) and 0xFF).toByte(),
|
((payload.size shr 8) and 0xFF).toByte(),
|
||||||
(payload.size and 0xFF).toByte(),
|
(payload.size and 0xFF).toByte(),
|
||||||
)
|
)
|
||||||
if (frameType != TYPE_FIRST || totalSize == null) return header + payload
|
if (totalSize == null) return header + payload
|
||||||
val extended = byteArrayOf(
|
val extended = byteArrayOf(
|
||||||
((totalSize shr 24) and 0xFF).toByte(),
|
((totalSize shr 24) and 0xFF).toByte(),
|
||||||
((totalSize shr 16) and 0xFF).toByte(),
|
((totalSize shr 16) and 0xFF).toByte(),
|
||||||
@@ -77,6 +76,43 @@ object Frames {
|
|||||||
return header + extended + payload
|
return header + extended + payload
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Splits plaintext before encryption; FIRST carries the complete plaintext length. */
|
||||||
|
fun writeMessage(
|
||||||
|
output: OutputStream,
|
||||||
|
channel: Int,
|
||||||
|
encrypted: Boolean,
|
||||||
|
messageType: Int,
|
||||||
|
message: ByteArray,
|
||||||
|
encrypt: (ByteArray) -> ByteArray,
|
||||||
|
): Int {
|
||||||
|
require(message.size in 2..MAX_MESSAGE_SIZE)
|
||||||
|
val fragmented = message.size > FRAGMENT_SIZE - HEADER_SIZE
|
||||||
|
require(messageType != MESSAGE_CONTROL || !fragmented) { "Channel control messages cannot be fragmented" }
|
||||||
|
var offset = 0
|
||||||
|
var frames = 0
|
||||||
|
while (offset < message.size) {
|
||||||
|
val first = fragmented && offset == 0
|
||||||
|
val headerSize = if (first) FIRST_HEADER_SIZE else HEADER_SIZE
|
||||||
|
val end = minOf(offset + FRAGMENT_SIZE - headerSize, message.size)
|
||||||
|
val type = when {
|
||||||
|
!fragmented -> TYPE_BULK
|
||||||
|
first -> TYPE_FIRST
|
||||||
|
end == message.size -> TYPE_LAST
|
||||||
|
else -> TYPE_MIDDLE
|
||||||
|
}
|
||||||
|
val chunk = message.copyOfRange(offset, end)
|
||||||
|
output.write(build(channel, encrypted, if (encrypted) encrypt(chunk) else chunk,
|
||||||
|
messageType, type, if (type == TYPE_FIRST) message.size else null))
|
||||||
|
offset = end
|
||||||
|
frames++
|
||||||
|
}
|
||||||
|
return frames
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local resource limits, not protocol constants. No supported media packet needs more.
|
||||||
|
private const val MAX_MESSAGE_SIZE = 16 * 1024 * 1024
|
||||||
|
private const val MAX_BUFFERED_SIZE = 32 * 1024 * 1024
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pulls whole frames out of an accumulating buffer.
|
* Pulls whole frames out of an accumulating buffer.
|
||||||
*
|
*
|
||||||
@@ -87,6 +123,8 @@ object Frames {
|
|||||||
private var pending = ByteArray(0)
|
private var pending = ByteArray(0)
|
||||||
|
|
||||||
fun append(data: ByteArray, length: Int) {
|
fun append(data: ByteArray, length: Int) {
|
||||||
|
require(length in 0..data.size)
|
||||||
|
if (length > MAX_BUFFERED_SIZE - pending.size) throw IOException("Frame buffer limit exceeded")
|
||||||
val grown = ByteArray(pending.size + length)
|
val grown = ByteArray(pending.size + length)
|
||||||
System.arraycopy(pending, 0, grown, 0, pending.size)
|
System.arraycopy(pending, 0, grown, 0, pending.size)
|
||||||
System.arraycopy(data, 0, grown, pending.size, length)
|
System.arraycopy(data, 0, grown, pending.size, length)
|
||||||
@@ -106,30 +144,53 @@ object Frames {
|
|||||||
val total = headerLength + frameSize
|
val total = headerLength + frameSize
|
||||||
if (pending.size < total) return null
|
if (pending.size < total) return null
|
||||||
|
|
||||||
|
val messageSize = if (frameType == TYPE_FIRST) {
|
||||||
|
var size = 0L
|
||||||
|
for (i in 4..7) size = (size shl 8) or (pending[i].toLong() and 0xff)
|
||||||
|
if (size !in 2..MAX_MESSAGE_SIZE.toLong()) throw IOException("Invalid fragmented message length: $size")
|
||||||
|
size.toInt()
|
||||||
|
} else null
|
||||||
val payload = pending.copyOfRange(headerLength, total)
|
val payload = pending.copyOfRange(headerLength, total)
|
||||||
pending = pending.copyOfRange(total, pending.size)
|
pending = pending.copyOfRange(total, pending.size)
|
||||||
return Frame(
|
return Frame(
|
||||||
channel, frameType, (flags and ENCRYPTED) != 0, flags and MESSAGE_CONTROL, payload,
|
channel, frameType, (flags and ENCRYPTED) != 0, flags and MESSAGE_CONTROL, payload, messageSize,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Rejoins FIRST/MIDDLE/LAST runs. Payloads arrive already decrypted. */
|
/** Rejoins messages on independent channels, validating their plaintext length and flags. */
|
||||||
class Assembler {
|
class Assembler {
|
||||||
private val partial = HashMap<Int, ByteArrayOutputStream>()
|
private class Partial(val first: Frame, val data: ByteArrayOutputStream = ByteArrayOutputStream())
|
||||||
|
private val partial = HashMap<Int, Partial>()
|
||||||
|
private var buffered = 0
|
||||||
|
|
||||||
fun offer(frame: Frame, payload: ByteArray): ByteArray? = when (frame.frameType) {
|
fun offer(frame: Frame, payload: ByteArray): ByteArray? {
|
||||||
TYPE_BULK -> payload
|
if (frame.frameType == TYPE_BULK) {
|
||||||
TYPE_FIRST -> {
|
if (partial.containsKey(frame.channel)) fail("Complete message interrupted fragments")
|
||||||
partial[frame.channel] = ByteArrayOutputStream().apply { write(payload) }
|
return payload
|
||||||
null
|
|
||||||
}
|
}
|
||||||
TYPE_MIDDLE -> {
|
val message = if (frame.frameType == TYPE_FIRST) {
|
||||||
partial[frame.channel]?.write(payload)
|
if (frame.totalSize == null || partial.containsKey(frame.channel)) fail("Invalid first fragment")
|
||||||
null
|
Partial(frame).also { partial[frame.channel] = it }
|
||||||
}
|
} else partial[frame.channel] ?: fail("Fragment without a first frame")
|
||||||
TYPE_LAST -> partial.remove(frame.channel)?.apply { write(payload) }?.toByteArray()
|
val first = message.first
|
||||||
else -> null
|
if (first.encrypted != frame.encrypted || first.messageType != frame.messageType) fail("Fragment flags changed")
|
||||||
|
val expected = first.totalSize ?: fail("Missing total size")
|
||||||
|
if (expected !in 2..MAX_MESSAGE_SIZE || payload.size > expected - message.data.size()) fail("Fragment exceeds message length")
|
||||||
|
if (payload.size > MAX_BUFFERED_SIZE - buffered) fail("Fragment buffer limit exceeded")
|
||||||
|
message.data.write(payload)
|
||||||
|
buffered += payload.size
|
||||||
|
if (frame.frameType != TYPE_LAST) return null
|
||||||
|
if (message.data.size() != expected) fail("Truncated fragmented message")
|
||||||
|
partial.remove(frame.channel)
|
||||||
|
buffered -= message.data.size()
|
||||||
|
return message.data.toByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fail(message: String): Nothing {
|
||||||
|
partial.clear()
|
||||||
|
buffered = 0
|
||||||
|
throw IOException(message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -17,9 +17,13 @@ import app.seb3thehacker.gearslip.car.ScreenFit
|
|||||||
import app.seb3thehacker.gearslip.car.CarSensors
|
import app.seb3thehacker.gearslip.car.CarSensors
|
||||||
import app.seb3thehacker.gearslip.car.CarServices
|
import app.seb3thehacker.gearslip.car.CarServices
|
||||||
import app.seb3thehacker.gearslip.car.CarSettings
|
import app.seb3thehacker.gearslip.car.CarSettings
|
||||||
|
import java.io.IOException
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
|
import java.util.concurrent.Executors
|
||||||
import java.util.concurrent.FutureTask
|
import java.util.concurrent.FutureTask
|
||||||
|
import java.util.concurrent.RejectedExecutionException
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Drives the phone side of the control-channel handshake far enough to answer one question:
|
* Drives the phone side of the control-channel handshake far enough to answer one question:
|
||||||
@@ -61,6 +65,7 @@ class GearslipRunner(
|
|||||||
/** Waits for the certificate only when TLS is first needed; see [run]. */
|
/** Waits for the certificate only when TLS is first needed; see [run]. */
|
||||||
private val tls: PhoneTls by lazy { identityTask.get() }
|
private val tls: PhoneTls by lazy { identityTask.get() }
|
||||||
private var authenticationFailed = false
|
private var authenticationFailed = false
|
||||||
|
private var drainWriterOnExit = false
|
||||||
@Volatile private var stopped = false
|
@Volatile private var stopped = false
|
||||||
|
|
||||||
@Volatile private var state = State.WAIT_VERSION
|
@Volatile private var state = State.WAIT_VERSION
|
||||||
@@ -154,6 +159,8 @@ class GearslipRunner(
|
|||||||
reportDisconnect()
|
reportDisconnect()
|
||||||
} finally {
|
} finally {
|
||||||
running = false
|
running = false
|
||||||
|
writer.shutdown()
|
||||||
|
if (drainWriterOnExit) writer.awaitTermination(2, TimeUnit.SECONDS)
|
||||||
writer.shutdownNow()
|
writer.shutdownNow()
|
||||||
log.flush()
|
log.flush()
|
||||||
}
|
}
|
||||||
@@ -192,7 +199,7 @@ class GearslipRunner(
|
|||||||
|
|
||||||
private fun handleFrame(frame: Frames.Frame) {
|
private fun handleFrame(frame: Frames.Frame) {
|
||||||
// A rejected handshake cannot consume later messages as if authentication succeeded.
|
// A rejected handshake cannot consume later messages as if authentication succeeded.
|
||||||
if (authenticationFailed || stopped) return
|
if (authenticationFailed || stopped || !running) return
|
||||||
val payload = if (frame.encrypted) {
|
val payload = if (frame.encrypted) {
|
||||||
if (!tls.handshakeComplete) {
|
if (!tls.handshakeComplete) {
|
||||||
log.w("encrypted frame arrived before the handshake finished - ignoring")
|
log.w("encrypted frame arrived before the handshake finished - ignoring")
|
||||||
@@ -203,7 +210,10 @@ class GearslipRunner(
|
|||||||
frame.payload
|
frame.payload
|
||||||
}
|
}
|
||||||
|
|
||||||
val message = assembler.offer(frame, payload) ?: return
|
val message = if (frame.messageType == Frames.MESSAGE_CONTROL) {
|
||||||
|
if (frame.frameType != Frames.TYPE_BULK) throw IOException("Fragmented channel control message")
|
||||||
|
payload
|
||||||
|
} else assembler.offer(frame, payload) ?: return
|
||||||
if (message.size < 2) {
|
if (message.size < 2) {
|
||||||
log.w("runt message on channel ${frame.channel}")
|
log.w("runt message on channel ${frame.channel}")
|
||||||
return
|
return
|
||||||
@@ -215,6 +225,7 @@ class GearslipRunner(
|
|||||||
lastProgress = System.currentTimeMillis()
|
lastProgress = System.currentTimeMillis()
|
||||||
|
|
||||||
if (frame.channel != Frames.CHANNEL_CONTROL) {
|
if (frame.channel != Frames.CHANNEL_CONTROL) {
|
||||||
|
if (state != State.DONE || !frame.encrypted) return
|
||||||
if (frame.channel == videoChannelId) {
|
if (frame.channel == videoChannelId) {
|
||||||
onVideoMessage(messageId, body)
|
onVideoMessage(messageId, body)
|
||||||
} else if (frame.channel == inputChannelId) {
|
} else if (frame.channel == inputChannelId) {
|
||||||
@@ -250,6 +261,9 @@ class GearslipRunner(
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (messageId !in setOf(MSG_VERSION_REQUEST, MSG_ENCAPSULATED_SSL, MSG_AUTH_COMPLETE,
|
||||||
|
MSG_BYEBYE_REQUEST, MSG_UNEXPECTED_MESSAGE, MSG_FRAMING_ERROR) &&
|
||||||
|
(!frame.encrypted || state != State.DONE && state != State.WAIT_SDR)) return
|
||||||
|
|
||||||
when (messageId) {
|
when (messageId) {
|
||||||
MSG_VERSION_REQUEST -> onVersionRequest(body)
|
MSG_VERSION_REQUEST -> onVersionRequest(body)
|
||||||
@@ -258,11 +272,20 @@ class GearslipRunner(
|
|||||||
MSG_SERVICE_DISCOVERY_RESPONSE -> onServiceDiscoveryResponse(body)
|
MSG_SERVICE_DISCOVERY_RESPONSE -> onServiceDiscoveryResponse(body)
|
||||||
MSG_PING_REQUEST -> onPingRequest(body)
|
MSG_PING_REQUEST -> onPingRequest(body)
|
||||||
MSG_BYEBYE_REQUEST -> onByeByeRequest(body)
|
MSG_BYEBYE_REQUEST -> onByeByeRequest(body)
|
||||||
|
MSG_UNEXPECTED_MESSAGE, MSG_FRAMING_ERROR -> onProtocolError(messageId)
|
||||||
AudioLink.MSG_FOCUS_RESPONSE -> audioLink?.onFocus(Protobuf.readInt32Field(body, 1) ?: 0)
|
AudioLink.MSG_FOCUS_RESPONSE -> audioLink?.onFocus(Protobuf.readInt32Field(body, 1) ?: 0)
|
||||||
else -> log.i("unhandled control message id=$messageId (${body.size} bytes) - continuing")
|
else -> log.i("unhandled control message id=$messageId (${body.size} bytes) - continuing")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun onProtocolError(messageId: Int) {
|
||||||
|
val detail = if (messageId == MSG_FRAMING_ERROR) "framing error" else "unexpected message"
|
||||||
|
log.w("head unit terminated the protocol: $detail")
|
||||||
|
SessionReport.fail(SessionReport.Category.UNKNOWN, detail, state.name, "protocol $messageId")
|
||||||
|
SessionStatus.failed("Protocol error", "The head unit reported a $detail.")
|
||||||
|
running = false
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The head unit asking to end the session. It drops USB a few seconds later whether or not
|
* The head unit asking to end the session. It drops USB a few seconds later whether or not
|
||||||
* we answer, so the point here is the reason: an Audi MIB2+ sent this about a second after
|
* we answer, so the point here is the reason: an Audi MIB2+ sent this about a second after
|
||||||
@@ -276,16 +299,23 @@ class GearslipRunner(
|
|||||||
3 -> "NOT_SUPPORTED"
|
3 -> "NOT_SUPPORTED"
|
||||||
4 -> "NOT_CURRENTLY_SUPPORTED"
|
4 -> "NOT_CURRENTLY_SUPPORTED"
|
||||||
5 -> "PROBE_SUPPORTED"
|
5 -> "PROBE_SUPPORTED"
|
||||||
|
6 -> "WIRELESS_PROJECTION_DISABLED"
|
||||||
|
7 -> "POWER_DOWN"
|
||||||
|
8 -> "USER_PROFILE_SWITCH"
|
||||||
else -> "unknown"
|
else -> "unknown"
|
||||||
}
|
}
|
||||||
log.w("<- ByeByeRequest: reason=$reason ($name) in state $state")
|
log.w("<- ByeByeRequest: reason=$reason ($name) in state $state")
|
||||||
log.hex(" byebye raw", body)
|
log.hex(" byebye raw", body)
|
||||||
SessionReport.fail(SessionReport.Category.BYEBYE, "ByeByeRequest reason=$reason ($name)", state.name, "bye $reason")
|
SessionReport.fail(SessionReport.Category.BYEBYE, "ByeByeRequest reason=$reason ($name)", state.name, "bye $reason")
|
||||||
send(MSG_BYEBYE_RESPONSE, ByteArray(0), encrypted = true)
|
send(MSG_BYEBYE_RESPONSE, ByteArray(0), encrypted = state == State.WAIT_SDR || state == State.DONE)
|
||||||
log.i("-> ByeByeResponse")
|
log.i("-> ByeByeResponse")
|
||||||
|
drainWriterOnExit = true
|
||||||
|
running = false
|
||||||
|
SessionStatus.disconnected()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onVersionRequest(body: ByteArray) {
|
private fun onVersionRequest(body: ByteArray) {
|
||||||
|
if (state != State.WAIT_VERSION) return
|
||||||
if (body.size < 4) {
|
if (body.size < 4) {
|
||||||
log.w("VersionRequest too short (${body.size} bytes)")
|
log.w("VersionRequest too short (${body.size} bytes)")
|
||||||
return
|
return
|
||||||
@@ -295,24 +325,16 @@ class GearslipRunner(
|
|||||||
log.i("<- VersionRequest: head unit speaks $major.$minor")
|
log.i("<- VersionRequest: head unit speaks $major.$minor")
|
||||||
SessionReport.protocolVersion(major, minor)
|
SessionReport.protocolVersion(major, minor)
|
||||||
|
|
||||||
// Always 1.3, on purpose. Every car that accepts Gearslip was tested this way, 1.5 units
|
val response = ProtocolVersion.respond(major, minor)
|
||||||
// included. Echoing the car's own version was tried too: a 4.3 unit rejected the
|
send(MSG_VERSION_RESPONSE, response.bytes, encrypted = false)
|
||||||
// certificate either way, so the version doesn't decide acceptance. The log says so, since
|
log.i("-> VersionResponse: ${response.major}.${response.minor}, supported=${response.supported}")
|
||||||
// a car offering 1.5 and getting 1.3 back looks like a bug to anyone reading it.
|
if (!response.supported) {
|
||||||
val claim = CLAIM_VERSION ?: (major to minor)
|
authenticationFailed = true
|
||||||
val response = byteArrayOf(
|
drainWriterOnExit = true
|
||||||
((claim.first shr 8) and 0xFF).toByte(), (claim.first and 0xFF).toByte(),
|
running = false
|
||||||
((claim.second shr 8) and 0xFF).toByte(), (claim.second and 0xFF).toByte(),
|
SessionStatus.failed("Unsupported protocol", "The head unit requested protocol $major.$minor.")
|
||||||
0, 0, // STATUS_SUCCESS
|
return
|
||||||
)
|
}
|
||||||
send(MSG_VERSION_RESPONSE, response, encrypted = false)
|
|
||||||
log.i(
|
|
||||||
"-> VersionResponse: ${claim.first}.${claim.second} status=0 (STATUS_SUCCESS)" +
|
|
||||||
if (CLAIM_VERSION != null && claim != (major to minor)) {
|
|
||||||
" - intended: Gearslip always answers ${claim.first}.${claim.second}, which cars " +
|
|
||||||
"offering $major.$minor accept too"
|
|
||||||
} else "",
|
|
||||||
)
|
|
||||||
state = State.TLS_HANDSHAKE
|
state = State.TLS_HANDSHAKE
|
||||||
SessionStatus.connecting("Securing the link")
|
SessionStatus.connecting("Securing the link")
|
||||||
log.i("awaiting ClientHello - the head unit is the TLS client, we are the server")
|
log.i("awaiting ClientHello - the head unit is the TLS client, we are the server")
|
||||||
@@ -329,17 +351,13 @@ class GearslipRunner(
|
|||||||
if (stopped) return
|
if (stopped) return
|
||||||
log.e("TLS handshake threw", t)
|
log.e("TLS handshake threw", t)
|
||||||
SessionReport.fail(SessionReport.Category.TLS, "handshake threw", t, state.name)
|
SessionReport.fail(SessionReport.Category.TLS, "handshake threw", t, state.name)
|
||||||
log.verdict(
|
|
||||||
"NOT VIABLE (TLS rejected)",
|
|
||||||
"The head unit aborted the TLS handshake. A bad_certificate / unknown_ca / " +
|
|
||||||
"handshake_failure alert here means it validates the phone's certificate.",
|
|
||||||
)
|
|
||||||
SessionStatus.failed(
|
SessionStatus.failed(
|
||||||
"Head unit rejected the certificate",
|
"Secure connection failed",
|
||||||
"The head unit aborted the secure handshake. It validates the phone's certificate.",
|
"The secure handshake could not be completed. Check the session log for details.",
|
||||||
)
|
)
|
||||||
state = State.DONE
|
state = State.DONE
|
||||||
authenticationFailed = true
|
authenticationFailed = true
|
||||||
|
running = false
|
||||||
SessionReport.print()
|
SessionReport.print()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -356,7 +374,7 @@ class GearslipRunner(
|
|||||||
|
|
||||||
private fun onAuthComplete(body: ByteArray) {
|
private fun onAuthComplete(body: ByteArray) {
|
||||||
val status = Protobuf.readInt32Field(body, 1)
|
val status = Protobuf.readInt32Field(body, 1)
|
||||||
if (state != State.WAIT_AUTH && !(state == State.TLS_HANDSHAKE && status in listOf(-2, -3))) return
|
if (state != State.WAIT_AUTH && !(state == State.TLS_HANDSHAKE && status != null && status != 0)) return
|
||||||
log.i("<- AuthComplete: status=$status")
|
log.i("<- AuthComplete: status=$status")
|
||||||
status?.let { SessionReport.auth(it) }
|
status?.let { SessionReport.auth(it) }
|
||||||
|
|
||||||
@@ -367,17 +385,17 @@ class GearslipRunner(
|
|||||||
SessionStatus.connecting("Certificate accepted - discovering services")
|
SessionStatus.connecting("Certificate accepted - discovering services")
|
||||||
sendServiceDiscoveryRequest()
|
sendServiceDiscoveryRequest()
|
||||||
}
|
}
|
||||||
-2 -> {
|
-2, -23, -24 -> {
|
||||||
SessionReport.fail(SessionReport.Category.CERTIFICATE, "AuthComplete status -2 (certificate error)", state.name, "auth -2")
|
val reason = when (status) {
|
||||||
log.verdict(
|
-23 -> "The phone's certificate is not valid yet."
|
||||||
"NOT VIABLE (STATUS_CERTIFICATE_ERROR)",
|
-24 -> "The phone's certificate has expired."
|
||||||
"The head unit explicitly rejected the phone's self-signed certificate. " +
|
else -> "The head unit refused the phone's certificate."
|
||||||
"This is a definitive no - it validates the phone against a trust chain " +
|
}
|
||||||
"we cannot issue from.",
|
SessionReport.fail(SessionReport.Category.CERTIFICATE, "AuthComplete status $status", state.name, "auth $status")
|
||||||
)
|
log.w("certificate rejected: $reason")
|
||||||
SessionStatus.failed(
|
SessionStatus.failed(
|
||||||
"Head unit rejected the certificate",
|
"Head unit rejected the certificate",
|
||||||
"The head unit refused the phone's certificate (certificate error).",
|
reason,
|
||||||
)
|
)
|
||||||
state = State.DONE
|
state = State.DONE
|
||||||
authenticationFailed = true
|
authenticationFailed = true
|
||||||
@@ -408,6 +426,7 @@ class GearslipRunner(
|
|||||||
authenticationFailed = true
|
authenticationFailed = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (authenticationFailed) running = false
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun sendServiceDiscoveryRequest() {
|
private fun sendServiceDiscoveryRequest() {
|
||||||
@@ -418,6 +437,7 @@ class GearslipRunner(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun onServiceDiscoveryResponse(body: ByteArray) {
|
private fun onServiceDiscoveryResponse(body: ByteArray) {
|
||||||
|
if (state != State.WAIT_SDR) return
|
||||||
log.i("<- ServiceDiscoveryResponse (${body.size} bytes, decrypted successfully)")
|
log.i("<- ServiceDiscoveryResponse (${body.size} bytes, decrypted successfully)")
|
||||||
// Field 5 is the car's serial number, and the Bluetooth service carries the car's
|
// Field 5 is the car's serial number, and the Bluetooth service carries the car's
|
||||||
// Bluetooth address. Either one identifies the car, and logs get shared, so both stay out.
|
// Bluetooth address. Either one identifies the car, and logs get shared, so both stay out.
|
||||||
@@ -1250,8 +1270,12 @@ class GearslipRunner(
|
|||||||
private fun onPingRequest(body: ByteArray) {
|
private fun onPingRequest(body: ByteArray) {
|
||||||
// The timestamp is an int64 and has to come back unchanged. Read as an int32 it was cut
|
// The timestamp is an int64 and has to come back unchanged. Read as an int32 it was cut
|
||||||
// short, and a car that checks the echo (an Audi MIB2+) gave up at its second ping.
|
// short, and a car that checks the echo (an Audi MIB2+) gave up at its second ping.
|
||||||
val timestamp = Protobuf.readInt64Field(body, 1) ?: System.nanoTime()
|
val fields = Wire.fields(body)
|
||||||
send(MSG_PING_RESPONSE, Protobuf.varintField(1, timestamp), encrypted = tls.handshakeComplete)
|
val timestamp = Wire.varint(fields, 1) ?: return
|
||||||
|
val payload = Wire.bytes(fields, 3)
|
||||||
|
val response = Protobuf.varintField(1, timestamp) +
|
||||||
|
(payload?.let { Protobuf.bytesField(2, it) } ?: byteArrayOf())
|
||||||
|
send(MSG_PING_RESPONSE, response, encrypted = tls.handshakeComplete)
|
||||||
log.i("<- PingRequest(timestamp=$timestamp) / -> PingResponse (keeping the session alive)")
|
log.i("<- PingRequest(timestamp=$timestamp) / -> PingResponse (keeping the session alive)")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1273,41 +1297,16 @@ class GearslipRunner(
|
|||||||
try {
|
try {
|
||||||
writer.execute {
|
writer.execute {
|
||||||
try {
|
try {
|
||||||
if (payload.size <= Frames.MAX_FRAME_PAYLOAD) {
|
val frameCount = Frames.writeMessage(output, channel, encrypted, messageType, payload) { tls.encrypt(it) }
|
||||||
val framePayload = if (encrypted) tls.encrypt(payload) else payload
|
if (frameCount > 1) splitMessages++
|
||||||
output.write(Frames.build(channel, encrypted, framePayload, messageType))
|
|
||||||
} else {
|
|
||||||
// Split exactly as aasdk does: chunk the *plaintext*, encrypt each chunk
|
|
||||||
// separately, and carry the total plaintext length in the FIRST frame.
|
|
||||||
// Video keyframes for anything busier than a test card exceed 16 KB routinely.
|
|
||||||
var offset = 0
|
|
||||||
while (offset < payload.size) {
|
|
||||||
val size = minOf(Frames.MAX_FRAME_PAYLOAD, payload.size - offset)
|
|
||||||
val chunk = payload.copyOfRange(offset, offset + size)
|
|
||||||
val frameType = when {
|
|
||||||
offset == 0 -> Frames.TYPE_FIRST
|
|
||||||
offset + size >= payload.size -> Frames.TYPE_LAST
|
|
||||||
else -> Frames.TYPE_MIDDLE
|
|
||||||
}
|
|
||||||
val framePayload = if (encrypted) tls.encrypt(chunk) else chunk
|
|
||||||
output.write(
|
|
||||||
Frames.build(
|
|
||||||
channel, encrypted, framePayload, messageType, frameType,
|
|
||||||
if (frameType == Frames.TYPE_FIRST) payload.size else null,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
offset += size
|
|
||||||
}
|
|
||||||
splitMessages++
|
|
||||||
}
|
|
||||||
output.flush()
|
output.flush()
|
||||||
} catch (e: java.io.IOException) {
|
} catch (e: IOException) {
|
||||||
if (running) log.w("send failed, stopping: ${e.message}")
|
if (running) log.w("send failed, stopping: ${e.message}")
|
||||||
running = false
|
running = false
|
||||||
videoSource?.stop()
|
videoSource?.stop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: java.util.concurrent.RejectedExecutionException) {
|
} catch (e: RejectedExecutionException) {
|
||||||
// The session is over; nothing more goes out.
|
// The session is over; nothing more goes out.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1403,13 +1402,6 @@ class GearslipRunner(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
/**
|
|
||||||
* The protocol version Gearslip answers every car with, as (major, minor), or null to echo
|
|
||||||
* the head unit's. Kept at 1.3 because every working car was tested with it; see
|
|
||||||
* onVersionRequest.
|
|
||||||
*/
|
|
||||||
val CLAIM_VERSION: Pair<Int, Int>? = 1 to 3
|
|
||||||
|
|
||||||
// protobuf/aap_protobuf/service/control/ControlMessageType.proto:7-17
|
// protobuf/aap_protobuf/service/control/ControlMessageType.proto:7-17
|
||||||
const val MSG_VERSION_REQUEST = 1
|
const val MSG_VERSION_REQUEST = 1
|
||||||
const val MSG_VERSION_RESPONSE = 2
|
const val MSG_VERSION_RESPONSE = 2
|
||||||
@@ -1422,6 +1414,8 @@ class GearslipRunner(
|
|||||||
const val MSG_BYEBYE_REQUEST = 15
|
const val MSG_BYEBYE_REQUEST = 15
|
||||||
const val MSG_BYEBYE_RESPONSE = 16
|
const val MSG_BYEBYE_RESPONSE = 16
|
||||||
const val MSG_VOICE_SESSION_NOTIFICATION = 17
|
const val MSG_VOICE_SESSION_NOTIFICATION = 17
|
||||||
|
const val MSG_UNEXPECTED_MESSAGE = 255
|
||||||
|
const val MSG_FRAMING_ERROR = 65535
|
||||||
const val MSG_CHANNEL_OPEN_REQUEST = 7
|
const val MSG_CHANNEL_OPEN_REQUEST = 7
|
||||||
const val MSG_CHANNEL_OPEN_RESPONSE = 8
|
const val MSG_CHANNEL_OPEN_RESPONSE = 8
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package app.seb3thehacker.gearslip
|
package app.seb3thehacker.gearslip
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
import java.net.Socket
|
import java.net.Socket
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import java.security.KeyStore
|
import java.security.KeyStore
|
||||||
@@ -11,23 +12,10 @@ import javax.net.ssl.SSLEngine
|
|||||||
import javax.net.ssl.SSLEngineResult
|
import javax.net.ssl.SSLEngineResult
|
||||||
import javax.net.ssl.SSLEngineResult.HandshakeStatus
|
import javax.net.ssl.SSLEngineResult.HandshakeStatus
|
||||||
import javax.net.ssl.SSLPeerUnverifiedException
|
import javax.net.ssl.SSLPeerUnverifiedException
|
||||||
|
import javax.net.ssl.SSLException
|
||||||
import javax.net.ssl.X509ExtendedTrustManager
|
import javax.net.ssl.X509ExtendedTrustManager
|
||||||
|
|
||||||
/**
|
/** TLS server for projection. Handshake records travel in control message 3, not a TLS socket. */
|
||||||
* The phone end of the Android Auto TLS session.
|
|
||||||
*
|
|
||||||
* Two things here are the opposite of what aasdk does, because aasdk implements the head
|
|
||||||
* unit and the head unit is the TLS *client*: it calls TLS_client_method() and
|
|
||||||
* SSL_set_connect_state (src/Transport/SSLWrapper.cpp:97-140, src/Messenger/Cryptor.cpp:87),
|
|
||||||
* and SSL_set_accept_state appears nowhere in that codebase. So the phone is the TLS
|
|
||||||
* **server** and its certificate goes out as the server certificate - sent unconditionally
|
|
||||||
* on every handshake, which is precisely what we are testing.
|
|
||||||
*
|
|
||||||
* TLS here is not layered over the socket: handshake bytes travel inside
|
|
||||||
* MESSAGE_ENCAPSULATED_SSL control messages (id 3). That rules out SSLSocket and makes
|
|
||||||
* SSLEngine - a buffer-to-buffer engine, the JSSE counterpart of aasdk's memory BIO pair -
|
|
||||||
* the right tool.
|
|
||||||
*/
|
|
||||||
class PhoneTls(keyStore: KeyStore, password: CharArray) {
|
class PhoneTls(keyStore: KeyStore, password: CharArray) {
|
||||||
private val log = GearslipLog.tagged("TLS")
|
private val log = GearslipLog.tagged("TLS")
|
||||||
|
|
||||||
@@ -36,7 +24,7 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
|
|||||||
private var appBuffer = ByteBuffer.allocate(32 * 1024)
|
private var appBuffer = ByteBuffer.allocate(32 * 1024)
|
||||||
private var netBuffer = ByteBuffer.allocate(32 * 1024)
|
private var netBuffer = ByteBuffer.allocate(32 * 1024)
|
||||||
|
|
||||||
var handshakeComplete: Boolean = false
|
@Volatile var handshakeComplete: Boolean = false
|
||||||
private set
|
private set
|
||||||
|
|
||||||
init {
|
init {
|
||||||
@@ -50,10 +38,9 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
|
|||||||
|
|
||||||
engine = context.createSSLEngine().apply {
|
engine = context.createSSLEngine().apply {
|
||||||
useClientMode = false
|
useClientMode = false
|
||||||
// want, not need: we ask the head unit for its certificate so the run also tells
|
// Android Auto's phone endpoint requires the head unit to present a certificate.
|
||||||
// us what real units present, but we never make the session depend on getting one.
|
needClientAuth = true
|
||||||
wantClientAuth = true
|
enabledProtocols = arrayOf("TLSv1.2")
|
||||||
enabledProtocols = arrayOf("TLSv1.2") // aasdk pins TLS 1.2
|
|
||||||
}
|
}
|
||||||
engine.beginHandshake()
|
engine.beginHandshake()
|
||||||
}
|
}
|
||||||
@@ -88,7 +75,7 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
|
|||||||
if (netBuffer.hasRemaining()) {
|
if (netBuffer.hasRemaining()) {
|
||||||
outgoing += ByteArray(netBuffer.remaining()).also { netBuffer.get(it) }
|
outgoing += ByteArray(netBuffer.remaining()).also { netBuffer.get(it) }
|
||||||
}
|
}
|
||||||
if (result.status == SSLEngineResult.Status.CLOSED) break@loop
|
if (result.status == SSLEngineResult.Status.CLOSED) throw SSLException("TLS closed during handshake")
|
||||||
}
|
}
|
||||||
|
|
||||||
HandshakeStatus.NEED_UNWRAP -> {
|
HandshakeStatus.NEED_UNWRAP -> {
|
||||||
@@ -100,7 +87,7 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
|
|||||||
appBuffer = ByteBuffer.allocate(appBuffer.capacity() * 2)
|
appBuffer = ByteBuffer.allocate(appBuffer.capacity() * 2)
|
||||||
continue@loop
|
continue@loop
|
||||||
}
|
}
|
||||||
SSLEngineResult.Status.CLOSED -> break@loop
|
SSLEngineResult.Status.CLOSED -> throw SSLException("TLS closed during handshake")
|
||||||
else -> Unit
|
else -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -124,7 +111,7 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
|
|||||||
/** Wraps a control-channel payload for an ENCRYPTED frame. */
|
/** Wraps a control-channel payload for an ENCRYPTED frame. */
|
||||||
fun encrypt(plain: ByteArray): ByteArray {
|
fun encrypt(plain: ByteArray): ByteArray {
|
||||||
val source = ByteBuffer.wrap(plain)
|
val source = ByteBuffer.wrap(plain)
|
||||||
val out = java.io.ByteArrayOutputStream()
|
val out = ByteArrayOutputStream()
|
||||||
while (source.hasRemaining()) {
|
while (source.hasRemaining()) {
|
||||||
netBuffer.clear()
|
netBuffer.clear()
|
||||||
val result = engine.wrap(source, netBuffer)
|
val result = engine.wrap(source, netBuffer)
|
||||||
@@ -132,6 +119,9 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
|
|||||||
netBuffer = ByteBuffer.allocate(netBuffer.capacity() * 2)
|
netBuffer = ByteBuffer.allocate(netBuffer.capacity() * 2)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if (result.status != SSLEngineResult.Status.OK || result.bytesConsumed() == 0) {
|
||||||
|
throw SSLException("TLS could not encrypt application data: ${result.status}")
|
||||||
|
}
|
||||||
netBuffer.flip()
|
netBuffer.flip()
|
||||||
val chunk = ByteArray(netBuffer.remaining())
|
val chunk = ByteArray(netBuffer.remaining())
|
||||||
netBuffer.get(chunk)
|
netBuffer.get(chunk)
|
||||||
@@ -140,14 +130,10 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
|
|||||||
return out.toByteArray()
|
return out.toByteArray()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Each encrypted projection frame contains complete TLS records. */
|
||||||
* Unwraps one ENCRYPTED frame's payload. aasdk decrypts per frame
|
|
||||||
* (MessageOutStream::compoundFrame encrypts per frame), so each frame's payload is
|
|
||||||
* self-contained ciphertext.
|
|
||||||
*/
|
|
||||||
fun decrypt(cipher: ByteArray): ByteArray {
|
fun decrypt(cipher: ByteArray): ByteArray {
|
||||||
val source = ByteBuffer.wrap(cipher)
|
val source = ByteBuffer.wrap(cipher)
|
||||||
val out = java.io.ByteArrayOutputStream()
|
val out = ByteArrayOutputStream()
|
||||||
while (source.hasRemaining()) {
|
while (source.hasRemaining()) {
|
||||||
appBuffer.clear()
|
appBuffer.clear()
|
||||||
val result = engine.unwrap(source, appBuffer)
|
val result = engine.unwrap(source, appBuffer)
|
||||||
@@ -157,10 +143,9 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
SSLEngineResult.Status.BUFFER_UNDERFLOW -> {
|
SSLEngineResult.Status.BUFFER_UNDERFLOW -> {
|
||||||
log.w("decrypt: record split across frames (${source.remaining()} bytes left over)")
|
throw SSLException("Incomplete TLS record in an encrypted frame")
|
||||||
break
|
|
||||||
}
|
}
|
||||||
SSLEngineResult.Status.CLOSED -> break
|
SSLEngineResult.Status.CLOSED -> throw SSLException("TLS peer closed the connection")
|
||||||
else -> Unit
|
else -> Unit
|
||||||
}
|
}
|
||||||
appBuffer.flip()
|
appBuffer.flip()
|
||||||
@@ -187,7 +172,7 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (_: SSLPeerUnverifiedException) {
|
} catch (_: SSLPeerUnverifiedException) {
|
||||||
log.i("head unit presented NO certificate (we asked but did not require one)")
|
log.i("head unit peer certificate is unavailable")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,14 +2,7 @@ package app.seb3thehacker.gearslip
|
|||||||
|
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
|
|
||||||
/**
|
/** Protobuf field encoding and diagnostic rendering for the projection protocol. */
|
||||||
* Just enough protobuf to run the spike, hand-rolled so there is no protoc step.
|
|
||||||
*
|
|
||||||
* Only three messages are ever written (AuthResponse is only read):
|
|
||||||
* ServiceDiscoveryRequest { optional string label_text = 4; optional string device_name = 5; }
|
|
||||||
* PingResponse { required int64 timestamp = 1; }
|
|
||||||
* AuthResponse { required int32 status = 1; }
|
|
||||||
*/
|
|
||||||
object Protobuf {
|
object Protobuf {
|
||||||
|
|
||||||
fun varint(value: Long): ByteArray {
|
fun varint(value: Long): ByteArray {
|
||||||
@@ -41,29 +34,7 @@ object Protobuf {
|
|||||||
fun readInt32Field(data: ByteArray, field: Int): Int? = readInt64Field(data, field)?.toInt()
|
fun readInt32Field(data: ByteArray, field: Int): Int? = readInt64Field(data, field)?.toInt()
|
||||||
|
|
||||||
/** Reads a varint field whole, for int64s such as a ping's timestamp. */
|
/** Reads a varint field whole, for int64s such as a ping's timestamp. */
|
||||||
fun readInt64Field(data: ByteArray, field: Int): Long? {
|
fun readInt64Field(data: ByteArray, field: Int): Long? = Wire.varint(Wire.fields(data), field)
|
||||||
var pos = 0
|
|
||||||
while (pos < data.size) {
|
|
||||||
val (tag, afterTag) = readVarint(data, pos) ?: return null
|
|
||||||
pos = afterTag
|
|
||||||
val number = (tag ushr 3).toInt()
|
|
||||||
when ((tag and 7L).toInt()) {
|
|
||||||
0 -> {
|
|
||||||
val (value, afterValue) = readVarint(data, pos) ?: return null
|
|
||||||
pos = afterValue
|
|
||||||
if (number == field) return value
|
|
||||||
}
|
|
||||||
2 -> {
|
|
||||||
val (len, afterLen) = readVarint(data, pos) ?: return null
|
|
||||||
pos = afterLen + len.toInt()
|
|
||||||
}
|
|
||||||
5 -> pos += 4
|
|
||||||
1 -> pos += 8
|
|
||||||
else -> return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shallow field dump. Used on ServiceDiscoveryResponse: its make/model/year strings
|
* Shallow field dump. Used on ServiceDiscoveryResponse: its make/model/year strings
|
||||||
@@ -71,6 +42,7 @@ object Protobuf {
|
|||||||
* proof that the session is live and we are decrypting correctly.
|
* proof that the session is live and we are decrypting correctly.
|
||||||
*/
|
*/
|
||||||
fun describe(data: ByteArray, indent: String = " "): String {
|
fun describe(data: ByteArray, indent: String = " "): String {
|
||||||
|
if (indent.length > 32) return "$indent(nested message)\n"
|
||||||
val sb = StringBuilder()
|
val sb = StringBuilder()
|
||||||
var pos = 0
|
var pos = 0
|
||||||
while (pos < data.size) {
|
while (pos < data.size) {
|
||||||
@@ -89,6 +61,7 @@ object Protobuf {
|
|||||||
}
|
}
|
||||||
2 -> {
|
2 -> {
|
||||||
val (len, afterLen) = readVarint(data, pos) ?: break
|
val (len, afterLen) = readVarint(data, pos) ?: break
|
||||||
|
if (len < 0 || len > data.size - afterLen) break
|
||||||
val end = afterLen + len.toInt()
|
val end = afterLen + len.toInt()
|
||||||
if (end > data.size) break
|
if (end > data.size) break
|
||||||
val body = data.copyOfRange(afterLen, end)
|
val body = data.copyOfRange(afterLen, end)
|
||||||
@@ -117,6 +90,7 @@ object Protobuf {
|
|||||||
var pos = start
|
var pos = start
|
||||||
while (pos < data.size && shift <= 63) {
|
while (pos < data.size && shift <= 63) {
|
||||||
val b = data[pos].toInt() and 0xFF
|
val b = data[pos].toInt() and 0xFF
|
||||||
|
if (shift == 63 && b > 1) return null
|
||||||
result = result or ((b and 0x7F).toLong() shl shift)
|
result = result or ((b and 0x7F).toLong() shl shift)
|
||||||
pos++
|
pos++
|
||||||
if (b and 0x80 == 0) return result to pos
|
if (b and 0x80 == 0) return result to pos
|
||||||
@@ -135,35 +109,37 @@ object Wire {
|
|||||||
val out = mutableListOf<Field>()
|
val out = mutableListOf<Field>()
|
||||||
var pos = 0
|
var pos = 0
|
||||||
while (pos < data.size) {
|
while (pos < data.size) {
|
||||||
val tag = readVarint(data, pos) ?: break
|
val tag = readVarint(data, pos) ?: return emptyList()
|
||||||
pos = tag.second
|
pos = tag.second
|
||||||
val number = (tag.first ushr 3).toInt()
|
val number = (tag.first ushr 3).toInt()
|
||||||
|
if (tag.first ushr 3 !in 1..0x1fff_ffffL) return emptyList()
|
||||||
when ((tag.first and 7L).toInt()) {
|
when ((tag.first and 7L).toInt()) {
|
||||||
0 -> {
|
0 -> {
|
||||||
val v = readVarint(data, pos) ?: break
|
val v = readVarint(data, pos) ?: return emptyList()
|
||||||
pos = v.second
|
pos = v.second
|
||||||
out += Field(number, 0, v.first, null)
|
out += Field(number, 0, v.first, null)
|
||||||
}
|
}
|
||||||
1 -> { if (pos + 8 > data.size) break; pos += 8 }
|
1 -> { if (data.size - pos < 8) return emptyList(); pos += 8 }
|
||||||
2 -> {
|
2 -> {
|
||||||
val len = readVarint(data, pos) ?: break
|
val len = readVarint(data, pos) ?: return emptyList()
|
||||||
|
if (len.first < 0 || len.first > data.size - len.second) return emptyList()
|
||||||
val end = len.second + len.first.toInt()
|
val end = len.second + len.first.toInt()
|
||||||
if (end > data.size || end < len.second) break
|
if (end > data.size || end < len.second) return emptyList()
|
||||||
out += Field(number, 2, 0, data.copyOfRange(len.second, end))
|
out += Field(number, 2, 0, data.copyOfRange(len.second, end))
|
||||||
pos = end
|
pos = end
|
||||||
}
|
}
|
||||||
5 -> { if (pos + 4 > data.size) break; pos += 4 }
|
5 -> { if (data.size - pos < 4) return emptyList(); pos += 4 }
|
||||||
else -> break
|
else -> return emptyList()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
fun varint(fields: List<Field>, number: Int): Long? =
|
fun varint(fields: List<Field>, number: Int): Long? =
|
||||||
fields.firstOrNull { it.number == number && it.wireType == 0 }?.varint
|
fields.lastOrNull { it.number == number && it.wireType == 0 }?.varint
|
||||||
|
|
||||||
fun bytes(fields: List<Field>, number: Int): ByteArray? =
|
fun bytes(fields: List<Field>, number: Int): ByteArray? =
|
||||||
fields.firstOrNull { it.number == number && it.wireType == 2 }?.bytes
|
fields.lastOrNull { it.number == number && it.wireType == 2 }?.bytes
|
||||||
|
|
||||||
fun allBytes(fields: List<Field>, number: Int): List<ByteArray> =
|
fun allBytes(fields: List<Field>, number: Int): List<ByteArray> =
|
||||||
fields.filter { it.number == number && it.wireType == 2 }.mapNotNull { it.bytes }
|
fields.filter { it.number == number && it.wireType == 2 }.mapNotNull { it.bytes }
|
||||||
@@ -174,6 +150,7 @@ object Wire {
|
|||||||
var pos = start
|
var pos = start
|
||||||
while (pos < data.size && shift <= 63) {
|
while (pos < data.size && shift <= 63) {
|
||||||
val b = data[pos].toInt() and 0xFF
|
val b = data[pos].toInt() and 0xFF
|
||||||
|
if (shift == 63 && b > 1) return null
|
||||||
result = result or ((b and 0x7F).toLong() shl shift)
|
result = result or ((b and 0x7F).toLong() shl shift)
|
||||||
pos++
|
pos++
|
||||||
if (b and 0x80 == 0) return result to pos
|
if (b and 0x80 == 0) return result to pos
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package app.seb3thehacker.gearslip
|
||||||
|
|
||||||
|
/** Advertises our supported 1.x version, as Android Auto does for older head units. */
|
||||||
|
internal object ProtocolVersion {
|
||||||
|
data class Response(val major: Int, val minor: Int, val supported: Boolean) {
|
||||||
|
val bytes: ByteArray get() = byteArrayOf(
|
||||||
|
(major ushr 8).toByte(), major.toByte(), (minor ushr 8).toByte(), minor.toByte(),
|
||||||
|
if (supported) 0 else -1, if (supported) 0 else -1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun respond(major: Int, minor: Int): Response = Response(1, 7, major == 1 && minor in 0..0xffff)
|
||||||
|
}
|
||||||
@@ -4,33 +4,25 @@ import java.net.Socket
|
|||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import java.security.SecureRandom
|
import java.security.SecureRandom
|
||||||
import java.security.cert.X509Certificate
|
import java.security.cert.X509Certificate
|
||||||
|
import javax.net.ssl.KeyManagerFactory
|
||||||
import javax.net.ssl.SSLContext
|
import javax.net.ssl.SSLContext
|
||||||
import javax.net.ssl.SSLEngine
|
import javax.net.ssl.SSLEngine
|
||||||
import javax.net.ssl.SSLEngineResult
|
import javax.net.ssl.SSLEngineResult
|
||||||
import javax.net.ssl.SSLEngineResult.HandshakeStatus
|
import javax.net.ssl.SSLEngineResult.HandshakeStatus
|
||||||
import javax.net.ssl.X509ExtendedTrustManager
|
import javax.net.ssl.X509ExtendedTrustManager
|
||||||
|
|
||||||
/**
|
/** Checks mutual TLS and an encrypted data round trip with an in-process head-unit peer. */
|
||||||
* Runs [PhoneTls] against an in-process TLS client and checks that a full handshake plus a
|
|
||||||
* data round trip works.
|
|
||||||
*
|
|
||||||
* This exists because of one specific hazard: a bug in the certificate, the key manager or
|
|
||||||
* the engine wiring produces a dead connection in the car that is indistinguishable from a
|
|
||||||
* head unit rejecting us - and the whole point of the spike is a trustworthy go/no-go
|
|
||||||
* signal. Run this on the bench first; if it passes, a failure in the car is about the head
|
|
||||||
* unit, not about this code.
|
|
||||||
*/
|
|
||||||
object TlsSelfTest {
|
object TlsSelfTest {
|
||||||
private val log = GearslipLog.tagged("TLS")
|
private val log = GearslipLog.tagged("TLS")
|
||||||
|
|
||||||
fun run(identity: CertProvider.Identity): Boolean {
|
fun run(identity: CertProvider.Identity, withClientCertificate: Boolean = true): Boolean {
|
||||||
log.i("=== TLS self-test starting ===")
|
log.i("=== TLS self-test starting ===")
|
||||||
log.i("cert source: ${identity.source}")
|
log.i("cert source: ${identity.source}")
|
||||||
log.i(" subject = ${identity.certificate.subjectX500Principal}")
|
log.i(" subject = ${identity.certificate.subjectX500Principal}")
|
||||||
log.i(" issuer = ${identity.certificate.issuerX500Principal}")
|
log.i(" issuer = ${identity.certificate.issuerX500Principal}")
|
||||||
return try {
|
return try {
|
||||||
val server = PhoneTls(identity.keyStore, identity.password)
|
val server = PhoneTls(identity.keyStore, identity.password)
|
||||||
val client = Driver(clientEngine())
|
val client = Driver(clientEngine(withClientCertificate))
|
||||||
|
|
||||||
var outbound = client.pump(null) // ClientHello
|
var outbound = client.pump(null) // ClientHello
|
||||||
var rounds = 0
|
var rounds = 0
|
||||||
@@ -49,8 +41,7 @@ object TlsSelfTest {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Data round trip, exercising the same encrypt path the spike uses for
|
// Exercise the same encrypt path used by ServiceDiscoveryRequest.
|
||||||
// ServiceDiscoveryRequest.
|
|
||||||
val plaintext = "gearslip round trip".toByteArray()
|
val plaintext = "gearslip round trip".toByteArray()
|
||||||
val decrypted = client.decrypt(server.encrypt(plaintext))
|
val decrypted = client.decrypt(server.encrypt(plaintext))
|
||||||
if (!decrypted.contentEquals(plaintext)) {
|
if (!decrypted.contentEquals(plaintext)) {
|
||||||
@@ -66,9 +57,15 @@ object TlsSelfTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun clientEngine(): SSLEngine {
|
private fun clientEngine(withCertificate: Boolean): SSLEngine {
|
||||||
val context = SSLContext.getInstance("TLSv1.2")
|
val context = SSLContext.getInstance("TLSv1.2")
|
||||||
context.init(null, arrayOf(TrustAll()), SecureRandom())
|
val keyManagers = if (withCertificate) {
|
||||||
|
val client = SelfSignedCert.generate(commonName = "Self-test head unit")
|
||||||
|
KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()).apply {
|
||||||
|
init(client.keyStore, SelfSignedCert.PASSWORD)
|
||||||
|
}.keyManagers
|
||||||
|
} else null
|
||||||
|
context.init(keyManagers, arrayOf(TrustAll()), SecureRandom())
|
||||||
return context.createSSLEngine().apply {
|
return context.createSSLEngine().apply {
|
||||||
useClientMode = true
|
useClientMode = true
|
||||||
enabledProtocols = arrayOf("TLSv1.2")
|
enabledProtocols = arrayOf("TLSv1.2")
|
||||||
|
|||||||
@@ -7,17 +7,20 @@ import java.io.InputStream
|
|||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import java.util.concurrent.CountDownLatch
|
import java.util.concurrent.CountDownLatch
|
||||||
import java.util.concurrent.ExecutorService
|
import java.util.concurrent.ExecutorService
|
||||||
|
import java.util.concurrent.FutureTask
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import javax.net.ssl.SSLContext
|
import javax.net.ssl.SSLContext
|
||||||
|
import org.junit.Assert.assertArrayEquals
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class ConnectionLifecycleTest {
|
class ConnectionLifecycleTest {
|
||||||
private val version = message(1, byteArrayOf(0, 1, 0, 3))
|
private val version = message(1, byteArrayOf(0, 1, 0, 3))
|
||||||
|
|
||||||
private fun message(id: Int, body: ByteArray = byteArrayOf()) = Frames.build(
|
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 {
|
private fun hello(): ByteArray {
|
||||||
@@ -41,13 +44,13 @@ class ConnectionLifecycleTest {
|
|||||||
private fun runner(input: InputStream) = GearslipRunner(input, ByteArrayOutputStream(), ::identity)
|
private fun runner(input: InputStream) = GearslipRunner(input, ByteArrayOutputStream(), ::identity)
|
||||||
|
|
||||||
@Test fun `rejected authentication ignores subsequent success and service discovery in the same read`() {
|
@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(
|
runner(ByteArrayInputStream(
|
||||||
version + hello() + message(4, Protobuf.varintField(1, status.toLong())) +
|
version + hello() + message(4, Protobuf.varintField(1, status.toLong())) +
|
||||||
message(4, Protobuf.varintField(1, 0)) + message(6) + version,
|
message(4, Protobuf.varintField(1, 0)) + message(6) + version,
|
||||||
)).run()
|
)).run()
|
||||||
assertEquals(
|
assertEquals(
|
||||||
if (status == -2) SessionReport.Category.CERTIFICATE else SessionReport.Category.AUTH,
|
if (status == -3) SessionReport.Category.AUTH else SessionReport.Category.CERTIFICATE,
|
||||||
SessionReport.category,
|
SessionReport.category,
|
||||||
)
|
)
|
||||||
assertEquals(SessionStatus.Phase.FAILED, SessionStatus.state.value.phase)
|
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`() {
|
@Test fun `TLS failure ignores later protocol messages`() {
|
||||||
val fatalAlert = message(3, byteArrayOf(21, 3, 3, 0, 2, 2, 42))
|
val fatalAlert = message(3, byteArrayOf(21, 3, 3, 0, 2, 2, 42))
|
||||||
runner(ByteArrayInputStream(version + hello() + fatalAlert + version + message(6))).run()
|
runner(ByteArrayInputStream(version + hello() + fatalAlert + version + message(6))).run()
|
||||||
assertEquals(SessionReport.Category.TLS, SessionReport.category)
|
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)
|
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`() {
|
@Test fun `authentication success before TLS completes is ignored`() {
|
||||||
runner(ByteArrayInputStream(version + message(4, Protobuf.varintField(1, 0)))).run()
|
runner(ByteArrayInputStream(version + message(4, Protobuf.varintField(1, 0)))).run()
|
||||||
assertEquals(SessionReport.Category.TLS, SessionReport.category)
|
assertEquals(SessionReport.Category.TLS, SessionReport.category)
|
||||||
@@ -133,4 +192,41 @@ class ConnectionLifecycleTest {
|
|||||||
assertTrue((field.get(runner) as ExecutorService).isShutdown)
|
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
|
package app.seb3thehacker.gearslip
|
||||||
|
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
@@ -42,6 +43,14 @@ class CryptoTest {
|
|||||||
assertTrue(TlsSelfTest.run(identity))
|
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
|
@Test
|
||||||
fun `frames round-trip through the parser`() {
|
fun `frames round-trip through the parser`() {
|
||||||
val payload = ByteArray(300) { it.toByte() }
|
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