mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-11 00:12:24 +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
|
||||
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.OutputStream
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
/** Android Auto framing with plaintext fragmentation before per-frame encryption. */
|
||||
object Frames {
|
||||
|
||||
const val CHANNEL_CONTROL = 0
|
||||
@@ -36,8 +26,11 @@ object Frames {
|
||||
const val MESSAGE_SPECIFIC = 0
|
||||
const val MESSAGE_CONTROL = 1 shl 2
|
||||
|
||||
/** aasdk splits at this size (MessageOutStream.hpp:62). Nothing the spike sends is close. */
|
||||
const val MAX_FRAME_PAYLOAD = 0x4000
|
||||
// Android Auto's default fragment budget includes the framing header. TLS adds overhead.
|
||||
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(
|
||||
val channel: Int,
|
||||
@@ -45,6 +38,7 @@ object Frames {
|
||||
val encrypted: Boolean,
|
||||
val messageType: Int,
|
||||
val payload: ByteArray,
|
||||
val totalSize: Int? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -60,6 +54,11 @@ object Frames {
|
||||
frameType: Int = TYPE_BULK,
|
||||
totalSize: Int? = null,
|
||||
): 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 header = byteArrayOf(
|
||||
channel.toByte(),
|
||||
@@ -67,7 +66,7 @@ object Frames {
|
||||
((payload.size shr 8) 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(
|
||||
((totalSize shr 24) and 0xFF).toByte(),
|
||||
((totalSize shr 16) and 0xFF).toByte(),
|
||||
@@ -77,6 +76,43 @@ object Frames {
|
||||
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.
|
||||
*
|
||||
@@ -87,6 +123,8 @@ object Frames {
|
||||
private var pending = ByteArray(0)
|
||||
|
||||
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)
|
||||
System.arraycopy(pending, 0, grown, 0, pending.size)
|
||||
System.arraycopy(data, 0, grown, pending.size, length)
|
||||
@@ -106,30 +144,53 @@ object Frames {
|
||||
val total = headerLength + frameSize
|
||||
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)
|
||||
pending = pending.copyOfRange(total, pending.size)
|
||||
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 {
|
||||
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) {
|
||||
TYPE_BULK -> payload
|
||||
TYPE_FIRST -> {
|
||||
partial[frame.channel] = ByteArrayOutputStream().apply { write(payload) }
|
||||
null
|
||||
fun offer(frame: Frame, payload: ByteArray): ByteArray? {
|
||||
if (frame.frameType == TYPE_BULK) {
|
||||
if (partial.containsKey(frame.channel)) fail("Complete message interrupted fragments")
|
||||
return payload
|
||||
}
|
||||
TYPE_MIDDLE -> {
|
||||
partial[frame.channel]?.write(payload)
|
||||
null
|
||||
}
|
||||
TYPE_LAST -> partial.remove(frame.channel)?.apply { write(payload) }?.toByteArray()
|
||||
else -> null
|
||||
val message = if (frame.frameType == TYPE_FIRST) {
|
||||
if (frame.totalSize == null || partial.containsKey(frame.channel)) fail("Invalid first fragment")
|
||||
Partial(frame).also { partial[frame.channel] = it }
|
||||
} else partial[frame.channel] ?: fail("Fragment without a first frame")
|
||||
val first = message.first
|
||||
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.CarServices
|
||||
import app.seb3thehacker.gearslip.car.CarSettings
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.util.concurrent.Executors
|
||||
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:
|
||||
@@ -61,6 +65,7 @@ class GearslipRunner(
|
||||
/** Waits for the certificate only when TLS is first needed; see [run]. */
|
||||
private val tls: PhoneTls by lazy { identityTask.get() }
|
||||
private var authenticationFailed = false
|
||||
private var drainWriterOnExit = false
|
||||
@Volatile private var stopped = false
|
||||
|
||||
@Volatile private var state = State.WAIT_VERSION
|
||||
@@ -154,6 +159,8 @@ class GearslipRunner(
|
||||
reportDisconnect()
|
||||
} finally {
|
||||
running = false
|
||||
writer.shutdown()
|
||||
if (drainWriterOnExit) writer.awaitTermination(2, TimeUnit.SECONDS)
|
||||
writer.shutdownNow()
|
||||
log.flush()
|
||||
}
|
||||
@@ -192,7 +199,7 @@ class GearslipRunner(
|
||||
|
||||
private fun handleFrame(frame: Frames.Frame) {
|
||||
// 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) {
|
||||
if (!tls.handshakeComplete) {
|
||||
log.w("encrypted frame arrived before the handshake finished - ignoring")
|
||||
@@ -203,7 +210,10 @@ class GearslipRunner(
|
||||
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) {
|
||||
log.w("runt message on channel ${frame.channel}")
|
||||
return
|
||||
@@ -215,6 +225,7 @@ class GearslipRunner(
|
||||
lastProgress = System.currentTimeMillis()
|
||||
|
||||
if (frame.channel != Frames.CHANNEL_CONTROL) {
|
||||
if (state != State.DONE || !frame.encrypted) return
|
||||
if (frame.channel == videoChannelId) {
|
||||
onVideoMessage(messageId, body)
|
||||
} else if (frame.channel == inputChannelId) {
|
||||
@@ -250,6 +261,9 @@ class GearslipRunner(
|
||||
}
|
||||
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) {
|
||||
MSG_VERSION_REQUEST -> onVersionRequest(body)
|
||||
@@ -258,11 +272,20 @@ class GearslipRunner(
|
||||
MSG_SERVICE_DISCOVERY_RESPONSE -> onServiceDiscoveryResponse(body)
|
||||
MSG_PING_REQUEST -> onPingRequest(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)
|
||||
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
|
||||
* 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"
|
||||
4 -> "NOT_CURRENTLY_SUPPORTED"
|
||||
5 -> "PROBE_SUPPORTED"
|
||||
6 -> "WIRELESS_PROJECTION_DISABLED"
|
||||
7 -> "POWER_DOWN"
|
||||
8 -> "USER_PROFILE_SWITCH"
|
||||
else -> "unknown"
|
||||
}
|
||||
log.w("<- ByeByeRequest: reason=$reason ($name) in state $state")
|
||||
log.hex(" byebye raw", body)
|
||||
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")
|
||||
drainWriterOnExit = true
|
||||
running = false
|
||||
SessionStatus.disconnected()
|
||||
}
|
||||
|
||||
private fun onVersionRequest(body: ByteArray) {
|
||||
if (state != State.WAIT_VERSION) return
|
||||
if (body.size < 4) {
|
||||
log.w("VersionRequest too short (${body.size} bytes)")
|
||||
return
|
||||
@@ -295,24 +325,16 @@ class GearslipRunner(
|
||||
log.i("<- VersionRequest: head unit speaks $major.$minor")
|
||||
SessionReport.protocolVersion(major, minor)
|
||||
|
||||
// Always 1.3, on purpose. Every car that accepts Gearslip was tested this way, 1.5 units
|
||||
// included. Echoing the car's own version was tried too: a 4.3 unit rejected the
|
||||
// certificate either way, so the version doesn't decide acceptance. The log says so, since
|
||||
// a car offering 1.5 and getting 1.3 back looks like a bug to anyone reading it.
|
||||
val claim = CLAIM_VERSION ?: (major to minor)
|
||||
val response = byteArrayOf(
|
||||
((claim.first shr 8) and 0xFF).toByte(), (claim.first and 0xFF).toByte(),
|
||||
((claim.second shr 8) and 0xFF).toByte(), (claim.second and 0xFF).toByte(),
|
||||
0, 0, // STATUS_SUCCESS
|
||||
)
|
||||
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 "",
|
||||
)
|
||||
val response = ProtocolVersion.respond(major, minor)
|
||||
send(MSG_VERSION_RESPONSE, response.bytes, encrypted = false)
|
||||
log.i("-> VersionResponse: ${response.major}.${response.minor}, supported=${response.supported}")
|
||||
if (!response.supported) {
|
||||
authenticationFailed = true
|
||||
drainWriterOnExit = true
|
||||
running = false
|
||||
SessionStatus.failed("Unsupported protocol", "The head unit requested protocol $major.$minor.")
|
||||
return
|
||||
}
|
||||
state = State.TLS_HANDSHAKE
|
||||
SessionStatus.connecting("Securing the link")
|
||||
log.i("awaiting ClientHello - the head unit is the TLS client, we are the server")
|
||||
@@ -329,17 +351,13 @@ class GearslipRunner(
|
||||
if (stopped) return
|
||||
log.e("TLS handshake threw", t)
|
||||
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(
|
||||
"Head unit rejected the certificate",
|
||||
"The head unit aborted the secure handshake. It validates the phone's certificate.",
|
||||
"Secure connection failed",
|
||||
"The secure handshake could not be completed. Check the session log for details.",
|
||||
)
|
||||
state = State.DONE
|
||||
authenticationFailed = true
|
||||
running = false
|
||||
SessionReport.print()
|
||||
return
|
||||
}
|
||||
@@ -356,7 +374,7 @@ class GearslipRunner(
|
||||
|
||||
private fun onAuthComplete(body: ByteArray) {
|
||||
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")
|
||||
status?.let { SessionReport.auth(it) }
|
||||
|
||||
@@ -367,17 +385,17 @@ class GearslipRunner(
|
||||
SessionStatus.connecting("Certificate accepted - discovering services")
|
||||
sendServiceDiscoveryRequest()
|
||||
}
|
||||
-2 -> {
|
||||
SessionReport.fail(SessionReport.Category.CERTIFICATE, "AuthComplete status -2 (certificate error)", state.name, "auth -2")
|
||||
log.verdict(
|
||||
"NOT VIABLE (STATUS_CERTIFICATE_ERROR)",
|
||||
"The head unit explicitly rejected the phone's self-signed certificate. " +
|
||||
"This is a definitive no - it validates the phone against a trust chain " +
|
||||
"we cannot issue from.",
|
||||
)
|
||||
-2, -23, -24 -> {
|
||||
val reason = when (status) {
|
||||
-23 -> "The phone's certificate is not valid yet."
|
||||
-24 -> "The phone's certificate has expired."
|
||||
else -> "The head unit refused the phone's certificate."
|
||||
}
|
||||
SessionReport.fail(SessionReport.Category.CERTIFICATE, "AuthComplete status $status", state.name, "auth $status")
|
||||
log.w("certificate rejected: $reason")
|
||||
SessionStatus.failed(
|
||||
"Head unit rejected the certificate",
|
||||
"The head unit refused the phone's certificate (certificate error).",
|
||||
reason,
|
||||
)
|
||||
state = State.DONE
|
||||
authenticationFailed = true
|
||||
@@ -408,6 +426,7 @@ class GearslipRunner(
|
||||
authenticationFailed = true
|
||||
}
|
||||
}
|
||||
if (authenticationFailed) running = false
|
||||
}
|
||||
|
||||
private fun sendServiceDiscoveryRequest() {
|
||||
@@ -418,6 +437,7 @@ class GearslipRunner(
|
||||
}
|
||||
|
||||
private fun onServiceDiscoveryResponse(body: ByteArray) {
|
||||
if (state != State.WAIT_SDR) return
|
||||
log.i("<- ServiceDiscoveryResponse (${body.size} bytes, decrypted successfully)")
|
||||
// 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.
|
||||
@@ -1250,8 +1270,12 @@ class GearslipRunner(
|
||||
private fun onPingRequest(body: ByteArray) {
|
||||
// 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.
|
||||
val timestamp = Protobuf.readInt64Field(body, 1) ?: System.nanoTime()
|
||||
send(MSG_PING_RESPONSE, Protobuf.varintField(1, timestamp), encrypted = tls.handshakeComplete)
|
||||
val fields = Wire.fields(body)
|
||||
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)")
|
||||
}
|
||||
|
||||
@@ -1273,41 +1297,16 @@ class GearslipRunner(
|
||||
try {
|
||||
writer.execute {
|
||||
try {
|
||||
if (payload.size <= Frames.MAX_FRAME_PAYLOAD) {
|
||||
val framePayload = if (encrypted) tls.encrypt(payload) else payload
|
||||
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++
|
||||
}
|
||||
val frameCount = Frames.writeMessage(output, channel, encrypted, messageType, payload) { tls.encrypt(it) }
|
||||
if (frameCount > 1) splitMessages++
|
||||
output.flush()
|
||||
} catch (e: java.io.IOException) {
|
||||
} catch (e: IOException) {
|
||||
if (running) log.w("send failed, stopping: ${e.message}")
|
||||
running = false
|
||||
videoSource?.stop()
|
||||
}
|
||||
}
|
||||
} catch (e: java.util.concurrent.RejectedExecutionException) {
|
||||
} catch (e: RejectedExecutionException) {
|
||||
// The session is over; nothing more goes out.
|
||||
}
|
||||
}
|
||||
@@ -1403,13 +1402,6 @@ class GearslipRunner(
|
||||
}
|
||||
|
||||
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
|
||||
const val MSG_VERSION_REQUEST = 1
|
||||
const val MSG_VERSION_RESPONSE = 2
|
||||
@@ -1422,6 +1414,8 @@ class GearslipRunner(
|
||||
const val MSG_BYEBYE_REQUEST = 15
|
||||
const val MSG_BYEBYE_RESPONSE = 16
|
||||
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_RESPONSE = 8
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package app.seb3thehacker.gearslip
|
||||
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.net.Socket
|
||||
import java.nio.ByteBuffer
|
||||
import java.security.KeyStore
|
||||
@@ -11,23 +12,10 @@ import javax.net.ssl.SSLEngine
|
||||
import javax.net.ssl.SSLEngineResult
|
||||
import javax.net.ssl.SSLEngineResult.HandshakeStatus
|
||||
import javax.net.ssl.SSLPeerUnverifiedException
|
||||
import javax.net.ssl.SSLException
|
||||
import javax.net.ssl.X509ExtendedTrustManager
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** TLS server for projection. Handshake records travel in control message 3, not a TLS socket. */
|
||||
class PhoneTls(keyStore: KeyStore, password: CharArray) {
|
||||
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 netBuffer = ByteBuffer.allocate(32 * 1024)
|
||||
|
||||
var handshakeComplete: Boolean = false
|
||||
@Volatile var handshakeComplete: Boolean = false
|
||||
private set
|
||||
|
||||
init {
|
||||
@@ -50,10 +38,9 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
|
||||
|
||||
engine = context.createSSLEngine().apply {
|
||||
useClientMode = false
|
||||
// want, not need: we ask the head unit for its certificate so the run also tells
|
||||
// us what real units present, but we never make the session depend on getting one.
|
||||
wantClientAuth = true
|
||||
enabledProtocols = arrayOf("TLSv1.2") // aasdk pins TLS 1.2
|
||||
// Android Auto's phone endpoint requires the head unit to present a certificate.
|
||||
needClientAuth = true
|
||||
enabledProtocols = arrayOf("TLSv1.2")
|
||||
}
|
||||
engine.beginHandshake()
|
||||
}
|
||||
@@ -88,7 +75,7 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
|
||||
if (netBuffer.hasRemaining()) {
|
||||
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 -> {
|
||||
@@ -100,7 +87,7 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
|
||||
appBuffer = ByteBuffer.allocate(appBuffer.capacity() * 2)
|
||||
continue@loop
|
||||
}
|
||||
SSLEngineResult.Status.CLOSED -> break@loop
|
||||
SSLEngineResult.Status.CLOSED -> throw SSLException("TLS closed during handshake")
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
@@ -124,7 +111,7 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
|
||||
/** Wraps a control-channel payload for an ENCRYPTED frame. */
|
||||
fun encrypt(plain: ByteArray): ByteArray {
|
||||
val source = ByteBuffer.wrap(plain)
|
||||
val out = java.io.ByteArrayOutputStream()
|
||||
val out = ByteArrayOutputStream()
|
||||
while (source.hasRemaining()) {
|
||||
netBuffer.clear()
|
||||
val result = engine.wrap(source, netBuffer)
|
||||
@@ -132,6 +119,9 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
|
||||
netBuffer = ByteBuffer.allocate(netBuffer.capacity() * 2)
|
||||
continue
|
||||
}
|
||||
if (result.status != SSLEngineResult.Status.OK || result.bytesConsumed() == 0) {
|
||||
throw SSLException("TLS could not encrypt application data: ${result.status}")
|
||||
}
|
||||
netBuffer.flip()
|
||||
val chunk = ByteArray(netBuffer.remaining())
|
||||
netBuffer.get(chunk)
|
||||
@@ -140,14 +130,10 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
|
||||
return out.toByteArray()
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwraps one ENCRYPTED frame's payload. aasdk decrypts per frame
|
||||
* (MessageOutStream::compoundFrame encrypts per frame), so each frame's payload is
|
||||
* self-contained ciphertext.
|
||||
*/
|
||||
/** Each encrypted projection frame contains complete TLS records. */
|
||||
fun decrypt(cipher: ByteArray): ByteArray {
|
||||
val source = ByteBuffer.wrap(cipher)
|
||||
val out = java.io.ByteArrayOutputStream()
|
||||
val out = ByteArrayOutputStream()
|
||||
while (source.hasRemaining()) {
|
||||
appBuffer.clear()
|
||||
val result = engine.unwrap(source, appBuffer)
|
||||
@@ -157,10 +143,9 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
|
||||
continue
|
||||
}
|
||||
SSLEngineResult.Status.BUFFER_UNDERFLOW -> {
|
||||
log.w("decrypt: record split across frames (${source.remaining()} bytes left over)")
|
||||
break
|
||||
throw SSLException("Incomplete TLS record in an encrypted frame")
|
||||
}
|
||||
SSLEngineResult.Status.CLOSED -> break
|
||||
SSLEngineResult.Status.CLOSED -> throw SSLException("TLS peer closed the connection")
|
||||
else -> Unit
|
||||
}
|
||||
appBuffer.flip()
|
||||
@@ -187,7 +172,7 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
|
||||
}
|
||||
}
|
||||
} 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
|
||||
|
||||
/**
|
||||
* 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; }
|
||||
*/
|
||||
/** Protobuf field encoding and diagnostic rendering for the projection protocol. */
|
||||
object Protobuf {
|
||||
|
||||
fun varint(value: Long): ByteArray {
|
||||
@@ -41,29 +34,7 @@ object Protobuf {
|
||||
fun readInt32Field(data: ByteArray, field: Int): Int? = readInt64Field(data, field)?.toInt()
|
||||
|
||||
/** Reads a varint field whole, for int64s such as a ping's timestamp. */
|
||||
fun readInt64Field(data: ByteArray, field: Int): Long? {
|
||||
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
|
||||
}
|
||||
fun readInt64Field(data: ByteArray, field: Int): Long? = Wire.varint(Wire.fields(data), field)
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
fun describe(data: ByteArray, indent: String = " "): String {
|
||||
if (indent.length > 32) return "$indent(nested message)\n"
|
||||
val sb = StringBuilder()
|
||||
var pos = 0
|
||||
while (pos < data.size) {
|
||||
@@ -89,6 +61,7 @@ object Protobuf {
|
||||
}
|
||||
2 -> {
|
||||
val (len, afterLen) = readVarint(data, pos) ?: break
|
||||
if (len < 0 || len > data.size - afterLen) break
|
||||
val end = afterLen + len.toInt()
|
||||
if (end > data.size) break
|
||||
val body = data.copyOfRange(afterLen, end)
|
||||
@@ -117,6 +90,7 @@ object Protobuf {
|
||||
var pos = start
|
||||
while (pos < data.size && shift <= 63) {
|
||||
val b = data[pos].toInt() and 0xFF
|
||||
if (shift == 63 && b > 1) return null
|
||||
result = result or ((b and 0x7F).toLong() shl shift)
|
||||
pos++
|
||||
if (b and 0x80 == 0) return result to pos
|
||||
@@ -135,35 +109,37 @@ object Wire {
|
||||
val out = mutableListOf<Field>()
|
||||
var pos = 0
|
||||
while (pos < data.size) {
|
||||
val tag = readVarint(data, pos) ?: break
|
||||
val tag = readVarint(data, pos) ?: return emptyList()
|
||||
pos = tag.second
|
||||
val number = (tag.first ushr 3).toInt()
|
||||
if (tag.first ushr 3 !in 1..0x1fff_ffffL) return emptyList()
|
||||
when ((tag.first and 7L).toInt()) {
|
||||
0 -> {
|
||||
val v = readVarint(data, pos) ?: break
|
||||
val v = readVarint(data, pos) ?: return emptyList()
|
||||
pos = v.second
|
||||
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 -> {
|
||||
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()
|
||||
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))
|
||||
pos = end
|
||||
}
|
||||
5 -> { if (pos + 4 > data.size) break; pos += 4 }
|
||||
else -> break
|
||||
5 -> { if (data.size - pos < 4) return emptyList(); pos += 4 }
|
||||
else -> return emptyList()
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
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? =
|
||||
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> =
|
||||
fields.filter { it.number == number && it.wireType == 2 }.mapNotNull { it.bytes }
|
||||
@@ -174,6 +150,7 @@ object Wire {
|
||||
var pos = start
|
||||
while (pos < data.size && shift <= 63) {
|
||||
val b = data[pos].toInt() and 0xFF
|
||||
if (shift == 63 && b > 1) return null
|
||||
result = result or ((b and 0x7F).toLong() shl shift)
|
||||
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.security.SecureRandom
|
||||
import java.security.cert.X509Certificate
|
||||
import javax.net.ssl.KeyManagerFactory
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.SSLEngine
|
||||
import javax.net.ssl.SSLEngineResult
|
||||
import javax.net.ssl.SSLEngineResult.HandshakeStatus
|
||||
import javax.net.ssl.X509ExtendedTrustManager
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Checks mutual TLS and an encrypted data round trip with an in-process head-unit peer. */
|
||||
object TlsSelfTest {
|
||||
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("cert source: ${identity.source}")
|
||||
log.i(" subject = ${identity.certificate.subjectX500Principal}")
|
||||
log.i(" issuer = ${identity.certificate.issuerX500Principal}")
|
||||
return try {
|
||||
val server = PhoneTls(identity.keyStore, identity.password)
|
||||
val client = Driver(clientEngine())
|
||||
val client = Driver(clientEngine(withClientCertificate))
|
||||
|
||||
var outbound = client.pump(null) // ClientHello
|
||||
var rounds = 0
|
||||
@@ -49,8 +41,7 @@ object TlsSelfTest {
|
||||
return false
|
||||
}
|
||||
|
||||
// Data round trip, exercising the same encrypt path the spike uses for
|
||||
// ServiceDiscoveryRequest.
|
||||
// Exercise the same encrypt path used by ServiceDiscoveryRequest.
|
||||
val plaintext = "gearslip round trip".toByteArray()
|
||||
val decrypted = client.decrypt(server.encrypt(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")
|
||||
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 {
|
||||
useClientMode = true
|
||||
enabledProtocols = arrayOf("TLSv1.2")
|
||||
|
||||
@@ -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