From bc03cda12bf13ec811fd3188d0ba83ecd5560629 Mon Sep 17 00:00:00 2001 From: Gaurav Ujjwal Date: Sun, 3 Nov 2024 21:54:35 +0530 Subject: [PATCH] Add support for Vencrypt's X509 sub-types Certificates are verified with trust-on-first-use policy. --- .../com/gaurav/avnc/util/KnownHostsTest.kt | 86 +++++++++++++++++++ .../java/com/gaurav/avnc/vnc/VncClientTest.kt | 2 + app/src/main/cpp/native-vnc.cpp | 23 ++++- .../java/com/gaurav/avnc/util/KnownHosts.kt | 86 +++++++++++++++++++ .../com/gaurav/avnc/viewmodel/VncViewModel.kt | 17 ++++ .../java/com/gaurav/avnc/vnc/VncClient.kt | 12 +++ extern/libvncserver | 2 +- 7 files changed, 223 insertions(+), 5 deletions(-) create mode 100644 app/src/androidTest/java/com/gaurav/avnc/util/KnownHostsTest.kt create mode 100644 app/src/main/java/com/gaurav/avnc/util/KnownHosts.kt diff --git a/app/src/androidTest/java/com/gaurav/avnc/util/KnownHostsTest.kt b/app/src/androidTest/java/com/gaurav/avnc/util/KnownHostsTest.kt new file mode 100644 index 00000000..c8c8af0f --- /dev/null +++ b/app/src/androidTest/java/com/gaurav/avnc/util/KnownHostsTest.kt @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2024 Gaurav Ujjwal. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * See COPYING.txt for more details. + */ + +package com.gaurav.avnc.util + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.gaurav.avnc.targetContext +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate + +@RunWith(AndroidJUnit4::class) +class KnownHostsTest { + + @Before + fun before() { + targetContext.filesDir.listFiles()?.forEach { it.deleteRecursively() } + } + + @Test + fun simpleTrustTest() { + Assert.assertFalse(isCertificateTrusted(targetContext, getTestCert())) + trustCertificate(targetContext, getTestCert()) + Assert.assertTrue(isCertificateTrusted(targetContext, getTestCert())) + } + + private fun getTestCert(): X509Certificate { + + // TLS certificate of example.com + val pem = """ + -----BEGIN CERTIFICATE----- + MIIHbjCCBlagAwIBAgIQB1vO8waJyK3fE+Ua9K/hhzANBgkqhkiG9w0BAQsFADBZ + MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMTMwMQYDVQQDEypE + aWdpQ2VydCBHbG9iYWwgRzIgVExTIFJTQSBTSEEyNTYgMjAyMCBDQTEwHhcNMjQw + MTMwMDAwMDAwWhcNMjUwMzAxMjM1OTU5WjCBljELMAkGA1UEBhMCVVMxEzARBgNV + BAgTCkNhbGlmb3JuaWExFDASBgNVBAcTC0xvcyBBbmdlbGVzMUIwQAYDVQQKDDlJ + bnRlcm5ldMKgQ29ycG9yYXRpb27CoGZvcsKgQXNzaWduZWTCoE5hbWVzwqBhbmTC + oE51bWJlcnMxGDAWBgNVBAMTD3d3dy5leGFtcGxlLm9yZzCCASIwDQYJKoZIhvcN + AQEBBQADggEPADCCAQoCggEBAIaFD7sO+cpf2fXgCjIsM9mqDgcpqC8IrXi9wga/ + 9y0rpqcnPVOmTMNLsid3INbBVEm4CNr5cKlh9rJJnWlX2vttJDRyLkfwBD+dsVvi + vGYxWTLmqX6/1LDUZPVrynv/cltemtg/1Aay88jcj2ZaRoRmqBgVeacIzgU8+zmJ + 7236TnFSe7fkoKSclsBhPaQKcE3Djs1uszJs8sdECQTdoFX9I6UgeLKFXtg7rRf/ + hcW5dI0zubhXbrW8aWXbCzySVZn0c7RkJMpnTCiZzNxnPXnHFpwr5quqqjVyN/aB + KkjoP04Zmr+eRqoyk/+lslq0sS8eaYSSHbC5ja/yMWyVhvMCAwEAAaOCA/IwggPu + MB8GA1UdIwQYMBaAFHSFgMBmx9833s+9KTeqAx2+7c0XMB0GA1UdDgQWBBRM/tAS + TS4hz2v68vK4TEkCHTGRijCBgQYDVR0RBHoweIIPd3d3LmV4YW1wbGUub3Jnggtl + eGFtcGxlLm5ldIILZXhhbXBsZS5lZHWCC2V4YW1wbGUuY29tggtleGFtcGxlLm9y + Z4IPd3d3LmV4YW1wbGUuY29tgg93d3cuZXhhbXBsZS5lZHWCD3d3dy5leGFtcGxl + Lm5ldDA+BgNVHSAENzA1MDMGBmeBDAECAjApMCcGCCsGAQUFBwIBFhtodHRwOi8v + d3d3LmRpZ2ljZXJ0LmNvbS9DUFMwDgYDVR0PAQH/BAQDAgWgMB0GA1UdJQQWMBQG + CCsGAQUFBwMBBggrBgEFBQcDAjCBnwYDVR0fBIGXMIGUMEigRqBEhkJodHRwOi8v + Y3JsMy5kaWdpY2VydC5jb20vRGlnaUNlcnRHbG9iYWxHMlRMU1JTQVNIQTI1NjIw + MjBDQTEtMS5jcmwwSKBGoESGQmh0dHA6Ly9jcmw0LmRpZ2ljZXJ0LmNvbS9EaWdp + Q2VydEdsb2JhbEcyVExTUlNBU0hBMjU2MjAyMENBMS0xLmNybDCBhwYIKwYBBQUH + AQEEezB5MCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdpY2VydC5jb20wUQYI + KwYBBQUHMAKGRWh0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydEds + b2JhbEcyVExTUlNBU0hBMjU2MjAyMENBMS0xLmNydDAMBgNVHRMBAf8EAjAAMIIB + fQYKKwYBBAHWeQIEAgSCAW0EggFpAWcAdABOdaMnXJoQwzhbbNTfP1LrHfDgjhuN + acCx+mSxYpo53wAAAY1b0vxkAAAEAwBFMEMCH0BRCgxPbBBVxhcWZ26a8JCe83P1 + JZ6wmv56GsVcyMACIDgpMbEo5HJITTRPnoyT4mG8cLrWjEvhchUdEcWUuk1TAHYA + fVkeEuF4KnscYWd8Xv340IdcFKBOlZ65Ay/ZDowuebgAAAGNW9L8MAAABAMARzBF + AiBdv5Z3pZFbfgoM3tGpCTM3ZxBMQsxBRSdTS6d8d2NAcwIhALLoCT9mTMN9OyFz + IBV5MkXVLyuTf2OAzAOa7d8x2H6XAHcA5tIxY0B3jMEQQQbXcbnOwdJA9paEhvu6 + hzId/R43jlAAAAGNW9L8XwAABAMASDBGAiEA4Koh/VizdQU1tjZ2E2VGgWSXXkwn + QmiYhmAeKcVLHeACIQD7JIGFsdGol7kss2pe4lYrCgPVc+iGZkuqnj26hqhr0TAN + BgkqhkiG9w0BAQsFAAOCAQEABOFuAj4N4yNG9OOWNQWTNSICC4Rd4nOG1HRP/Bsn + rz7KrcPORtb6D+Jx+Q0amhO31QhIvVBYs14gY4Ypyj7MzHgm4VmPXcqLvEkxb2G9 + Qv9hYuEiNSQmm1fr5QAN/0AzbEbCM3cImLJ69kP5bUjfv/76KB57is8tYf9sh5ik + LGKauxCM/zRIcGa3bXLDafk5S2g5Vr2hs230d/NGW1wZrE+zdGuMxfGJzJP+DAFv + iBfcQnFg4+1zMEKcqS87oniOyG+60RMM0MdejBD7AS43m9us96Gsun/4kufLQUTI + FfnzxLutUV++3seshgefQOy5C/ayi8y1VTNmujPCxPCi6Q== + -----END CERTIFICATE-----""".trimIndent() + + return pem.byteInputStream().use { + CertificateFactory.getInstance("X.509").generateCertificate(it) as X509Certificate + } + } +} diff --git a/app/src/androidTest/java/com/gaurav/avnc/vnc/VncClientTest.kt b/app/src/androidTest/java/com/gaurav/avnc/vnc/VncClientTest.kt index 9b70470d..1a5d9a88 100644 --- a/app/src/androidTest/java/com/gaurav/avnc/vnc/VncClientTest.kt +++ b/app/src/androidTest/java/com/gaurav/avnc/vnc/VncClientTest.kt @@ -12,6 +12,7 @@ import com.gaurav.avnc.TestServer import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test +import java.security.cert.X509Certificate class VncClientTest { @@ -20,6 +21,7 @@ class VncClientTest { override fun onPasswordRequired() = "" override fun onCredentialRequired() = UserCredential() + override fun onVerifyCertificate(certificate: X509Certificate) = false override fun onFramebufferUpdated() {} override fun onFramebufferSizeChanged(width: Int, height: Int) {} override fun onPointerMoved(x: Int, y: Int) {} diff --git a/app/src/main/cpp/native-vnc.cpp b/app/src/main/cpp/native-vnc.cpp index 684713bf..db896eb3 100644 --- a/app/src/main/cpp/native-vnc.cpp +++ b/app/src/main/cpp/native-vnc.cpp @@ -88,10 +88,11 @@ static char *onGetPassword(rfbClient *client) { } static rfbCredential *onGetCredential(rfbClient *client, int credentialType) { - if (credentialType != rfbCredentialTypeUser) { - //Only user credentials (i.e. username & password) are currently supported - rfbClientErr("Unsupported credential type requested"); - return nullptr; + if (credentialType == rfbCredentialTypeX509) { + // Return empty credentials here, server certificate will be verified later + auto credential = (rfbCredential *) malloc(sizeof(rfbCredential)); + memset(credential, 0, sizeof(rfbCredential)); + return credential; } auto obj = getManagedClient(client); @@ -122,6 +123,19 @@ static rfbCredential *onGetCredential(rfbClient *client, int credentialType) { return credential; } +static rfbBool onVerifyServerCertificate(rfbClient *client, const unsigned char *der, int der_len) { + auto obj = getManagedClient(client); + auto env = context.getEnv(); + auto cls = context.managedCls; + + jmethodID mid = env->GetMethodID(cls, "cbVerifyServerCertificate", "([B)Z"); + jbyteArray bytes = env->NewByteArray(der_len); + env->SetByteArrayRegion(bytes, 0, der_len, reinterpret_cast(der)); + auto result = env->CallBooleanMethod(obj, mid, bytes); + env->DeleteLocalRef(bytes); + return result ? TRUE : FALSE; +} + static void onBell(rfbClient *client) { auto obj = getManagedClient(client); auto env = context.getEnv(); @@ -244,6 +258,7 @@ static void onGotCursorShape(rfbClient *client, int xHot, int yHot, int width, i static void setCallbacks(rfbClient *client) { client->GetPassword = onGetPassword; client->GetCredential = onGetCredential; + client->VerifyServerCertificate = onVerifyServerCertificate; client->Bell = onBell; client->GotXCutText = onGotXCutTextLatin1; client->GotXCutTextUTF8 = onGotXCutTextUTF8; diff --git a/app/src/main/java/com/gaurav/avnc/util/KnownHosts.kt b/app/src/main/java/com/gaurav/avnc/util/KnownHosts.kt new file mode 100644 index 00000000..51c3fab0 --- /dev/null +++ b/app/src/main/java/com/gaurav/avnc/util/KnownHosts.kt @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2024 Gaurav Ujjwal. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * See COPYING.txt for more details. + */ + +package com.gaurav.avnc.util + +import android.content.Context +import android.util.Log +import com.google.crypto.tink.subtle.Hex +import java.io.File +import java.security.MessageDigest +import java.security.cert.Certificate +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate +import javax.security.auth.x500.X500Principal + +// Utilities related to known hosts & certificates + +private fun getTrustedCertsDir(context: Context) = File(context.filesDir, "trusted_certs") + +private fun getFileForTrustedCertificate(context: Context, certificate: Certificate): File { + val certDigest = MessageDigest.getInstance("SHA1").digest(certificate.encoded) + val certFile = Hex.encode(certDigest) + val certDir = getTrustedCertsDir(context) + return File(certDir, certFile) +} + +/** + * Adds given [certificate] to trusted list. + */ +fun trustCertificate(context: Context, certificate: Certificate) { + runCatching { + val certDir = getTrustedCertsDir(context) + val certFile = getFileForTrustedCertificate(context, certificate) + certDir.mkdirs() + certFile.writeBytes(certificate.encoded) + }.onFailure { + Log.e("KnownHosts", "Error trusting certificate", it) + } +} + +/** + * Checks whether given [certificate] is trusted. + */ +fun isCertificateTrusted(context: Context, certificate: Certificate): Boolean { + runCatching { + val trustedFile = getFileForTrustedCertificate(context, certificate) + if (!trustedFile.exists()) + return false + + // This should always succeed once file exists + val certFactory = CertificateFactory.getInstance("X.509") + val trustedCert = trustedFile.inputStream().use { certFactory.generateCertificate(it) } + if (trustedCert.equals(certificate)) + return true + }.onFailure { + Log.w("KnownHosts", "Error checking certificate", it) + } + return false +} + +@OptIn(ExperimentalStdlibApi::class) +fun getUnknownCertificateMessage(certificate: X509Certificate): String { + fun commonName(p: X500Principal) = p.name.split(',') // Doesn't handle escaped comma + .find { it.startsWith("CN=", true) } + ?.drop(3) ?: "Unknown" + + val subject = commonName(certificate.subjectX500Principal) + val issuer = commonName(certificate.issuerX500Principal) + val fingerprint = MessageDigest.getInstance("SHA1").digest(certificate.encoded) + .toHexString(HexFormat { upperCase = true; bytes { byteSeparator = " " } }) + + return """ + Certificate received from server is not trusted. Someone might be impersonating the server. + + Subject: $subject + Issuer: $issuer + Fingerprint (SHA1): $fingerprint + + Make sure you are connecting to right server. Click Continue to add this certificate to trusted list. + """.trimIndent() +} diff --git a/app/src/main/java/com/gaurav/avnc/viewmodel/VncViewModel.kt b/app/src/main/java/com/gaurav/avnc/viewmodel/VncViewModel.kt index cd0a588b..54613138 100644 --- a/app/src/main/java/com/gaurav/avnc/viewmodel/VncViewModel.kt +++ b/app/src/main/java/com/gaurav/avnc/viewmodel/VncViewModel.kt @@ -23,7 +23,10 @@ import com.gaurav.avnc.util.LiveRequest import com.gaurav.avnc.util.SingleShotFlag import com.gaurav.avnc.util.broadcastWoLPackets import com.gaurav.avnc.util.getClipboardText +import com.gaurav.avnc.util.getUnknownCertificateMessage +import com.gaurav.avnc.util.isCertificateTrusted import com.gaurav.avnc.util.setClipboardText +import com.gaurav.avnc.util.trustCertificate import com.gaurav.avnc.viewmodel.service.SshTunnel import com.gaurav.avnc.vnc.Messenger import com.gaurav.avnc.vnc.UserCredential @@ -32,6 +35,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.delay import java.io.IOException import java.lang.ref.WeakReference +import java.security.cert.X509Certificate import kotlin.concurrent.thread /** @@ -430,6 +434,19 @@ class VncViewModel(val profile: ServerProfile, app: Application) : BaseViewModel return getLoginInfo(LoginInfo.Type.VNC_CREDENTIAL).let { UserCredential(it.username, it.password) } } + override fun onVerifyCertificate(certificate: X509Certificate): Boolean { + if (isCertificateTrusted(app, certificate)) + return true + + val title = "Unknown server certificate" + val message = getUnknownCertificateMessage(certificate) + if (!confirmationRequest.requestResponse(Pair(title, message))) + return false + + trustCertificate(app, certificate) + return true + } + override fun onFramebufferUpdated() { frameViewRef.get()?.requestRender() } diff --git a/app/src/main/java/com/gaurav/avnc/vnc/VncClient.kt b/app/src/main/java/com/gaurav/avnc/vnc/VncClient.kt index 48413cd4..e028319c 100644 --- a/app/src/main/java/com/gaurav/avnc/vnc/VncClient.kt +++ b/app/src/main/java/com/gaurav/avnc/vnc/VncClient.kt @@ -1,9 +1,12 @@ package com.gaurav.avnc.vnc import androidx.annotation.Keep +import java.io.ByteArrayInputStream import java.io.IOException import java.nio.ByteBuffer import java.nio.charset.StandardCharsets +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate import java.util.concurrent.locks.ReentrantReadWriteLock import kotlin.concurrent.read import kotlin.concurrent.write @@ -43,6 +46,7 @@ class VncClient(private val observer: Observer) { interface Observer { fun onPasswordRequired(): String fun onCredentialRequired(): UserCredential + fun onVerifyCertificate(certificate: X509Certificate): Boolean fun onGotXCutText(text: String) fun onFramebufferUpdated() fun onFramebufferSizeChanged(width: Int, height: Int) @@ -343,6 +347,14 @@ class VncClient(private val observer: Observer) { @Keep private fun cbGetCredential() = observer.onCredentialRequired() + @Keep + private fun cbVerifyServerCertificate(der: ByteArray): Boolean { + val cert = ByteArrayInputStream(der).use { + CertificateFactory.getInstance("X.509").generateCertificate(it) + } + return observer.onVerifyCertificate(cert as X509Certificate) + } + @Keep private fun cbGotXCutText(bytes: ByteArray, isUTF8: Boolean) { (if (isUTF8) StandardCharsets.UTF_8 else StandardCharsets.ISO_8859_1).let { diff --git a/extern/libvncserver b/extern/libvncserver index c24fadbb..d44c2fef 160000 --- a/extern/libvncserver +++ b/extern/libvncserver @@ -1 +1 @@ -Subproject commit c24fadbbd8115f4c126856117a7cbe0e9b4400f4 +Subproject commit d44c2fef93d77a1ecf2871b50c783e470759393b