Sajeel
Sajeel

Reputation: 91

SSL Handshake Inconsistency in Android Compared to JVM

I’m new to socket programming and am trying to connect to the WhatsApp WebSocket in an Android application. Below is the Kotlin code I wrote for performing the SSL handshake. The same code runs successfully in a JVM environment but fails when executed on Android. I am only trying it on Android 14 devices.

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.launch
import java.io.EOFException
import java.net.InetSocketAddress
import java.nio.ByteBuffer
import java.nio.channels.AsynchronousSocketChannel
import java.nio.channels.CompletionHandler
import java.util.Base64
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLEngine
import javax.net.ssl.SSLEngineResult
import javax.net.ssl.SSLEngineResult.Status

object WhatsappTryHandshake {

    const val WEB_SOCKET_HOST: String = "web.whatsapp.com"
    const val WEB_SOCKET_PORT: Int = 443
    const val KEY_LENGTH = 16

    lateinit var socketChannel: AsynchronousSocketChannel
    lateinit var sslEngine: SSLEngine
    lateinit var clientKey: String
    lateinit var sslReadBuffer: ByteBuffer
    lateinit var netDataWrite: ByteBuffer
    lateinit var sslOutputBuffer: ByteBuffer
    private var isHandshakeCompleted = false

    suspend fun connectToWhatsAppWebSocket() {


        clientKey = Base64.getEncoder().encodeToString(ByteArray(KEY_LENGTH).apply { java.util.Random().nextBytes(this) })

        // SSL context for secure connection
        val sslContext = SSLContext.getInstance("TLSv1.3")
        sslContext.init(null, null, null)

        socketChannel = AsynchronousSocketChannel.open()
        val address = InetSocketAddress(WEB_SOCKET_HOST, WEB_SOCKET_PORT)
        socketChannel.connect(address).get()

        // Set up SSL Engine
        sslEngine = sslContext.createSSLEngine(WEB_SOCKET_HOST, WEB_SOCKET_PORT)
        sslEngine.useClientMode = true
        sslEngine.beginHandshake()

        // SSL Handshake
        netDataWrite = ByteBuffer.allocate(sslEngine.session.packetBufferSize)
        sslReadBuffer = ByteBuffer.allocate(sslEngine.session.packetBufferSize)
        sslReadBuffer.position(sslReadBuffer.limit())
        sslOutputBuffer = ByteBuffer.allocate(sslEngine.session.packetBufferSize)
        doHandshake()
    }

    private fun doHandshake(status: Status? = null) {

            println("${sslEngine.handshakeStatus}")
            when (sslEngine.handshakeStatus) {
                SSLEngineResult.HandshakeStatus.NEED_WRAP -> {
                    netDataWrite.clear()
                    val result = sslEngine.wrap(sslOutputBuffer, netDataWrite)
                    val isHandshakeFinished = isHandshakeFinished(result, true)
                    netDataWrite.flip()

                        socketChannel.write(netDataWrite, null, object : CompletionHandler<Int?, Any?>{
                            override fun completed(result: Int?, attachment: Any?) {
                                println("completed: $result")

                                if (isHandshakeFinished){
                                    finishSslHandshake()
                                }else{
                                    doHandshake()
                                }
                            }

                            override fun failed(exc: Throwable?, attachment: Any?) {
                                println("failed: ${exc?.message}")
                            }
                        })

                }
                SSLEngineResult.HandshakeStatus.NEED_UNWRAP -> {
                    sslReadBuffer.compact()
                    if (status != Status.BUFFER_UNDERFLOW && sslReadBuffer.position() != 0){
                        sslReadBuffer.flip()
                        doSSlHandshakeUnwrapOperation()
                    }else{

                        readPlain(sslReadBuffer, true){
                            doSSlHandshakeUnwrapOperation()
                        }
                    }

                }
                SSLEngineResult.HandshakeStatus.FINISHED -> {
                    finishSslHandshake()
                }
                SSLEngineResult.HandshakeStatus.NOT_HANDSHAKING -> {
                    finishSslHandshake()
                    println("Cannot complete Handshake")
                }
                SSLEngineResult.HandshakeStatus.NEED_TASK -> {
                    var runnable: Runnable? = sslEngine.delegatedTask
                    while (runnable != null) {
                        runnable.run()
                        runnable = sslEngine.delegatedTask
                    }

                    println("Handshake Status " + sslEngine.handshakeStatus )
                    doHandshake()
                }
                else -> throw IllegalStateException("Unknown handshake status")
            }
    }

    private fun doSSlHandshakeUnwrapOperation() {
        try {
            val result: SSLEngineResult = sslEngine.unwrap(sslReadBuffer, sslOutputBuffer)
            println(result.toString())
            if (isHandshakeFinished(result, false)) {
                finishSslHandshake()
            } else {
                doHandshake(result.status)
            }
        } catch (throwable: Throwable) {
            throwable.printStackTrace()
            finishSslHandshake()
        }
    }

    private fun finishSslHandshake() {
        isHandshakeCompleted = true
        sslOutputBuffer.clear()
    }

    private fun isHandshakeFinished(result: SSLEngineResult, wrap: Boolean): Boolean {
        val sslEngineStatus = result.status
        check(!(sslEngineStatus != SSLEngineResult.Status.OK && (wrap || sslEngineStatus != SSLEngineResult.Status.BUFFER_UNDERFLOW))) { "SSL handshake operation failed with status: $sslEngineStatus" }

        check(!(wrap && result.bytesConsumed() != 0)) {
            throw IllegalStateException("SSL handshake operation failed with status: no bytes consumed")
        }

        check(!(!wrap && result.bytesProduced() != 0)) { throw IllegalStateException("SSL handshake operation failed with status: no bytes produced") }

        val sslHandshakeStatus = result.handshakeStatus
        return sslHandshakeStatus == SSLEngineResult.HandshakeStatus.FINISHED
    }

    private fun readPlain(buffer: ByteBuffer, lastRead: Boolean, proceed: () -> Unit) {
        val outerCaller = RuntimeException()
        socketChannel.read(buffer, null, object : CompletionHandler<Int, Any?> {
            override fun completed(bytesRead: Int, attachment: Any?) {
                if (bytesRead == -1) {
                    val eof = EOFException()
                    eof.addSuppressed(outerCaller)
                    println("Failed: ${eof.message}")
                    return
                }

                if (lastRead) {
                    buffer.flip()
                }
                proceed()
            }

            override fun failed(exc: Throwable, attachment: Any?) {
                exc.addSuppressed(outerCaller)
                println("Failed: ${exc.message}")
            }
        })
    }

}

fun main(){

    CoroutineScope(IO).launch {
        WhatsappTryHandshake.connectToWhatsAppWebSocket()
    }

    while (true){
        //..
    }

}

Problem Description

  1. JVM Environment Behavior

    • The code runs successfully in a standalone Kotlin/JVM environment.
    • The handshake status transitions include NEED_WRAP, NEED_UNWRAP, and NEED_TASK as expected.
    • Final handshake completes successfully, as shown in the logs below.
  2. Android Environment Behavior

    • The handshake fails with an exception at the last NEED_WRAP stage.

    • The bytes produced in the final wrap operation differ between JVM and Android.

      • JVM: 74 bytes.
      • Android: 16470 bytes.

This discrepancy leads to an IllegalStateException with SSL handshake operation failed with status: no bytes consumed.

JVM Logs

NEED_WRAP
completed: 489
NEED_UNWRAP
Status = OK HandshakeStatus = NEED_TASK
bytesConsumed = 127 bytesProduced = 0
NEED_TASK
Handshake Status NEED_WRAP
NEED_WRAP
completed: 6
NEED_UNWRAP
Status = OK HandshakeStatus = NEED_UNWRAP
bytesConsumed = 6 bytesProduced = 0
NEED_UNWRAP
Status = OK HandshakeStatus = NEED_TASK
bytesConsumed = 1022 bytesProduced = 0
NEED_TASK
Handshake Status NEED_UNWRAP
NEED_UNWRAP
Status = OK HandshakeStatus = NEED_UNWRAP
bytesConsumed = 1522 bytesProduced = 0
NEED_UNWRAP
Status = BUFFER_UNDERFLOW HandshakeStatus = NEED_UNWRAP
bytesConsumed = 0 bytesProduced = 0
NEED_UNWRAP
Status = OK HandshakeStatus = NEED_TASK
bytesConsumed = 492 bytesProduced = 0
NEED_TASK
Handshake Status NEED_WRAP
NEED_WRAP
completed: 74

Android Logs

NEED_WRAP
completed: 517
NEED_UNWRAP
Status = OK HandshakeStatus = NEED_UNWRAP
bytesConsumed = 127 bytesProduced = 0
NEED_UNWRAP
Status = OK HandshakeStatus = NEED_UNWRAP
bytesConsumed = 6 bytesProduced = 0
NEED_UNWRAP
Status = OK HandshakeStatus = NEED_UNWRAP
bytesConsumed = 1022 bytesProduced = 0
NEED_UNWRAP
Status = OK HandshakeStatus = NEED_UNWRAP
bytesConsumed = 1522 bytesProduced = 0
NEED_UNWRAP
Status = OK HandshakeStatus = NEED_WRAP
bytesConsumed = 492 bytesProduced = 0
NEED_WRAP
java.lang.IllegalStateException: SSL handshake operation failed with status: no bytes consumed
    at com.example.whatsappwrapperdemo.whatsapp.WhatsappTryHandshake.isHandshakeFinished(Whatsapp_transport_handshake.kt:145)
    at com.example.whatsappwrapperdemo.whatsapp.WhatsappTryHandshake.doHandshake(Whatsapp_transport_handshake.kt:64)
    at com.example.whatsappwrapperdemo.whatsapp.WhatsappTryHandshake.doSSlHandshakeUnwrapOperation(Whatsapp_transport_handshake.kt:127)
    at com.example.whatsappwrapperdemo.whatsapp.WhatsappTryHandshake.doHandshake(Whatsapp_transport_handshake.kt:89)
    at com.example.whatsappwrapperdemo.whatsapp.WhatsappTryHandshake.doSSlHandshakeUnwrapOperation(Whatsapp_transport_handshake.kt:127)
    at com.example.whatsappwrapperdemo.whatsapp.WhatsappTryHandshake.doHandshake(Whatsapp_transport_handshake.kt:89)
    at com.example.whatsappwrapperdemo.whatsapp.WhatsappTryHandshake.doSSlHandshakeUnwrapOperation(Whatsapp_transport_handshake.kt:127)
    at com.example.whatsappwrapperdemo.whatsapp.WhatsappTryHandshake.doHandshake(Whatsapp_transport_handshake.kt:89)
    at com.example.whatsappwrapperdemo.whatsapp.WhatsappTryHandshake.doSSlHandshakeUnwrapOperation(Whatsapp_transport_handshake.kt:127)
    at com.example.whatsappwrapperdemo.whatsapp.WhatsappTryHandshake.doHandshake(Whatsapp_transport_handshake.kt:89)
    at com.example.whatsappwrapperdemo.whatsapp.WhatsappTryHandshake.doSSlHandshakeUnwrapOperation(Whatsapp_transport_handshake.kt:127)
    at com.example.whatsappwrapperdemo.whatsapp.WhatsappTryHandshake.access$doSSlHandshakeUnwrapOperation(Whatsapp_transport_handshake.kt:17)
    at com.example.whatsappwrapperdemo.whatsapp.WhatsappTryHandshake$doHandshake$2.invoke(Whatsapp_transport_handshake.kt:93)
    at com.example.whatsappwrapperdemo.whatsapp.WhatsappTryHandshake$doHandshake$2.invoke(Whatsapp_transport_handshake.kt:92)
    at com.example.whatsappwrapperdemo.whatsapp.WhatsappTryHandshake$readPlain$1.completed(Whatsapp_transport_handshake.kt:168)
    at com.example.whatsappwrapperdemo.whatsapp.WhatsappTryHandshake$readPlain$1.completed(Whatsapp_transport_handshake.kt:156)
    at sun.nio.ch.Invoker.invokeUnchecked(Invoker.java:126)
    at sun.nio.ch.UnixAsynchronousSocketChannelImpl.finishRead(UnixAsynchronousSocketChannelImpl.java:454)
    at sun.nio.ch.UnixAsynchronousSocketChannelImpl.finish(UnixAsynchronousSocketChannelImpl.java:201)
    at sun.nio.ch.UnixAsynchronousSocketChannelImpl.onEvent(UnixAsynchronousSocketChannelImpl.java:223)
    at sun.nio.ch.EPollPort$EventHandlerTask.run(EPollPort.java:293)
    at sun.nio.ch.AsynchronousChannelGroupImpl$1.run(AsynchronousChannelGroupImpl.java:112)
    at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1145)
    at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:644)
    at java.lang.Thread.run(Thread.java:1012)

Observations

  1. In the JVM environment, the handshake includes NEED_TASK, which does not appear in Android.
  2. The bytes produced in the final wrap operation differ significantly between JVM and Android.
  3. I thought there might be issues with certificates so I added certificate files in the app too, adding network config file below.
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
    <domain-config cleartextTrafficPermitted="true">
        <domain includeSubdomains="true">web.whatsapp.com</domain>
        <trust-anchors>

            <certificates src="@raw/digicert_high_assurance_ev_root_ca" />
            <certificates src="@raw/digicert_sha2_high_assurance_server_ca" />
            <certificates src="@raw/whatsapp" />
            <certificates src="system" />
        </trust-anchors>
    </domain-config>
</network-security-config>

Upvotes: 0

Views: 26

Answers (0)

Related Questions