From c04c35b5cddfa7bf16f65afb19b890561ba0f782 Mon Sep 17 00:00:00 2001 From: Iscle Date: Sun, 11 Oct 2026 23:52:12 +0200 Subject: [PATCH] Fix protocol framing, TLS, and connection negotiation --- .../app/seb3thehacker/gearslip/Framing.kt | 121 +++++++++++---- .../seb3thehacker/gearslip/GearslipRunner.kt | 146 +++++++++--------- .../app/seb3thehacker/gearslip/PhoneTls.kt | 51 +++--- .../app/seb3thehacker/gearslip/Protobuf.kt | 57 ++----- .../seb3thehacker/gearslip/ProtocolVersion.kt | 13 ++ .../app/seb3thehacker/gearslip/TlsSelfTest.kt | 29 ++-- .../gearslip/ConnectionLifecycleTest.kt | 104 ++++++++++++- .../app/seb3thehacker/gearslip/CryptoTest.kt | 9 ++ .../app/seb3thehacker/gearslip/FramingTest.kt | 94 +++++++++++ .../seb3thehacker/gearslip/ProtobufTest.kt | 24 +++ .../gearslip/ProtocolVersionTest.kt | 18 +++ 11 files changed, 467 insertions(+), 199 deletions(-) create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/ProtocolVersion.kt create mode 100644 gearslip/src/test/java/app/seb3thehacker/gearslip/FramingTest.kt create mode 100644 gearslip/src/test/java/app/seb3thehacker/gearslip/ProtobufTest.kt create mode 100644 gearslip/src/test/java/app/seb3thehacker/gearslip/ProtocolVersionTest.kt diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/Framing.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/Framing.kt index 4ecaf09..ed72167 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/Framing.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/Framing.kt @@ -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() + private class Partial(val first: Frame, val data: ByteArrayOutputStream = ByteArrayOutputStream()) + private val partial = HashMap() + 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) } } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt index e4741d6..c219050 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt @@ -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? = 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 diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/PhoneTls.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/PhoneTls.kt index c353cda..973c678 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/PhoneTls.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/PhoneTls.kt @@ -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") } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/Protobuf.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/Protobuf.kt index c651284..bf903da 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/Protobuf.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/Protobuf.kt @@ -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() 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, 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, 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, number: Int): List = 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 diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ProtocolVersion.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ProtocolVersion.kt new file mode 100644 index 0000000..ea7b906 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ProtocolVersion.kt @@ -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) +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/TlsSelfTest.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/TlsSelfTest.kt index c54b94c..741e190 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/TlsSelfTest.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/TlsSelfTest.kt @@ -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") diff --git a/gearslip/src/test/java/app/seb3thehacker/gearslip/ConnectionLifecycleTest.kt b/gearslip/src/test/java/app/seb3thehacker/gearslip/ConnectionLifecycleTest.kt index 2966b2e..db9b1a9 100644 --- a/gearslip/src/test/java/app/seb3thehacker/gearslip/ConnectionLifecycleTest.kt +++ b/gearslip/src/test/java/app/seb3thehacker/gearslip/ConnectionLifecycleTest.kt @@ -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) + } + } diff --git a/gearslip/src/test/java/app/seb3thehacker/gearslip/CryptoTest.kt b/gearslip/src/test/java/app/seb3thehacker/gearslip/CryptoTest.kt index 0ace3ef..b0e6b3a 100644 --- a/gearslip/src/test/java/app/seb3thehacker/gearslip/CryptoTest.kt +++ b/gearslip/src/test/java/app/seb3thehacker/gearslip/CryptoTest.kt @@ -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() } diff --git a/gearslip/src/test/java/app/seb3thehacker/gearslip/FramingTest.kt b/gearslip/src/test/java/app/seb3thehacker/gearslip/FramingTest.kt new file mode 100644 index 0000000..8787e44 --- /dev/null +++ b/gearslip/src/test/java/app/seb3thehacker/gearslip/FramingTest.kt @@ -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() } + } +} diff --git a/gearslip/src/test/java/app/seb3thehacker/gearslip/ProtobufTest.kt b/gearslip/src/test/java/app/seb3thehacker/gearslip/ProtobufTest.kt new file mode 100644 index 0000000..63ac789 --- /dev/null +++ b/gearslip/src/test/java/app/seb3thehacker/gearslip/ProtobufTest.kt @@ -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)) + } +} diff --git a/gearslip/src/test/java/app/seb3thehacker/gearslip/ProtocolVersionTest.kt b/gearslip/src/test/java/app/seb3thehacker/gearslip/ProtocolVersionTest.kt new file mode 100644 index 0000000..c71e7df --- /dev/null +++ b/gearslip/src/test/java/app/seb3thehacker/gearslip/ProtocolVersionTest.kt @@ -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) + } +}