Skip to content

Commit

Permalink
Add support for Vencrypt's X509 sub-types
Browse files Browse the repository at this point in the history
Certificates are verified with trust-on-first-use policy.
  • Loading branch information
gujjwal00 committed Nov 3, 2024
1 parent 94bac7e commit bc03cda
Show file tree
Hide file tree
Showing 7 changed files with 223 additions and 5 deletions.
86 changes: 86 additions & 0 deletions app/src/androidTest/java/com/gaurav/avnc/util/KnownHostsTest.kt
Original file line number Diff line number Diff line change
@@ -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
}
}
}
2 changes: 2 additions & 0 deletions app/src/androidTest/java/com/gaurav/avnc/vnc/VncClientTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand All @@ -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) {}
Expand Down
23 changes: 19 additions & 4 deletions app/src/main/cpp/native-vnc.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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<const jbyte *>(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();
Expand Down Expand Up @@ -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;
Expand Down
86 changes: 86 additions & 0 deletions app/src/main/java/com/gaurav/avnc/util/KnownHosts.kt
Original file line number Diff line number Diff line change
@@ -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()
}
17 changes: 17 additions & 0 deletions app/src/main/java/com/gaurav/avnc/viewmodel/VncViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

/**
Expand Down Expand Up @@ -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()
}
Expand Down
12 changes: 12 additions & 0 deletions app/src/main/java/com/gaurav/avnc/vnc/VncClient.kt
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion extern/libvncserver

0 comments on commit bc03cda

Please sign in to comment.