Creating mTLS reverse proxy with Ktor - (not)Unique experience

Creating mTLS reverse proxy with Ktor


If you like to integrate with external security systems, then you have probably come across such a thing as Mutual TLS (also known as TLS mutual authentication or mTLS). There is nothing particularly difficult in such integration. Except for two nuances:

  1. Integration will have to be done in each service
  2. In each service you need to put client certificates for passing the mTLS handshake

And if you can somehow live and put up with the first nuance, then the second already causes much more problems and leads to sad faces of your security team. To make them happy again, and at the same time eliminate the need to write the same code in each service, can be made by a smart reverse proxy, which will take all the hardships and hardships handshake related and possibly some other additional logic. We will do this farm on Ktor, because why not;)

To make them happy again, and at the same time get rid of the need to write the same code in each service, we will write a smart reverse proxy which will take care of all hardships associated with an mTLS handshake and other security logic. I will implement all these things with Ktor, because why not ;)

Foreplay

To avoid any surprises, I will immediately outline the technology stack on which we will do everything:

It also assumes that you already have Docker installed. Everything described below can be implemented without it, but Docker will help you very much.

MockServer configuration

To make everything work, you need to generate a client certificate that the server will know about. The simplest way of doing this is to use openssl utility.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
$ openssl req -newkey rsa:4096 -nodes -keyout client-cert.key -x509 -days 1337 -out client-cert.crt
Generating a 4096 bit RSA private key
.........................................................................................................................................................................................................................................................................................................................++
......................................++
writing new private key to 'client-cert.key'
-----
You are about to be asked to enter information that will be incorporated
into your certificate request.
What you are about to enter is what is called a Distinguished Name or a DN.
There are quite a few fields but you can leave some blank
For some fields there will be a default value,
If you enter '.', the field will be left blank.
-----
Country Name (2 letter code) []:RU
State or Province Name (full name) []:Moscow
Locality Name (eg, city) []:Moscow
Organization Name (eg, company) []:Secret
Organizational Unit Name (eg, section) []:Dev
Common Name (eg, fully qualified host name) []:localhost
Email Address []:admin@localhost

If everything was successful you will see two files:

  • client-cert.crt — certificate
  • client-cert.key — private key

Now you can run MockServer with mTLS support. Be careful when setting paths to client certificates on the host machine.

$ docker pull mockserver/mockserver
$ docker run -d --rm \
    -e MOCKSERVER_TLS_MUTUAL_AUTHENTICATION_REQUIRED=true \
    -e MOCKSERVER_TLS_MUTUAL_AUTHENTICATION_CERTIFICATE_CHAIN=/opt/client-cert.crt \
    -v $(pwd)/client-cert.crt:/opt/client-cert.crt \
    -P mockserver/mockserver

If you just execute GET / request now, it’ll fail because our server requires TLS mutual authentication.

1
2
3
$ curl -GET https://localhost:55000/
curl: (58) could not load PEM client certificate, LibreSSL error error:02FFF002:system library:func(4095):
No such file or directory, (no key found, wrong pass phrase, or wrong file format?)

But you can not pass client certificate and key only. You also need to pass CA certificate with the request.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
$ wget https://raw.githubusercontent.com/mock-server/mockserver/master/mockserver-core/src/main/resources/org/mockserver/socket/CertificateAuthorityCertificate.pem
$ curl -v -GET --cacert CertificateAuthorityCertificate.pem --cert client-cert.crt --key client-cert.key https://localhost:55000/
*   Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 55000 (#0)
* ALPN, offering h2
* ALPN, offering http/1.1
* successfully set certificate verify locations:
*   CAfile: CertificateAuthorityCertificate.pem
  CApath: none
* TLSv1.2 (OUT), TLS handshake, Client hello (1):
* TLSv1.2 (IN), TLS handshake, Server hello (2):
* TLSv1.2 (IN), TLS handshake, Certificate (11):
* TLSv1.2 (IN), TLS handshake, Server key exchange (12):
* TLSv1.2 (IN), TLS handshake, Request CERT (13):
* TLSv1.2 (IN), TLS handshake, Server finished (14):
* TLSv1.2 (OUT), TLS handshake, Certificate (11):
* TLSv1.2 (OUT), TLS handshake, Client key exchange (16):
* TLSv1.2 (OUT), TLS handshake, CERT verify (15):
* TLSv1.2 (OUT), TLS change cipher, Change cipher spec (1):
* TLSv1.2 (OUT), TLS handshake, Finished (20):
* TLSv1.2 (IN), TLS change cipher, Change cipher spec (1):
* TLSv1.2 (IN), TLS handshake, Finished (20):
* SSL connection using TLSv1.2 / ECDHE-RSA-AES256-GCM-SHA384
* ALPN, server did not agree to a protocol
* Server certificate:
*  subject: CN=localhost; O=MockServer; L=London; ST=England; C=UK
*  start date: Jul 26 12:43:37 2021 GMT
*  expire date: Jul 31 12:43:37 2022 GMT
*  subjectAltName: host "localhost" matched cert's "localhost"
*  issuer: CN=www.mockserver.com; O=MockServer; L=London; ST=England; C=UK
*  SSL certificate verify ok.
> GET / HTTP/1.1
> Host: localhost:55000
> User-Agent: curl/7.64.1
> Accept: */*
>
< HTTP/1.1 404 Not Found
< connection: keep-alive
< content-length: 0
<
* Connection #0 to host localhost left intact
* Closing connection 0

Excellent! Handshake has been passed successfully. Now we need to force the server to return something more useful than 404 Not Found.

1
2
3
4
5
6
7
8
9
$ curl -X PUT --cacert CertificateAuthorityCertificate.pem --cert client-cert.crt --key client-cert.key "https://localhost:55000/mockserver/openapi" -d '{
    "specUrlOrPayload": "https://raw.githubusercontent.com/mock-server/mockserver/master/mockserver-integration-testing/src/main/resources/org/mockserver/mock/openapi_petstore_example.json"
}'
$ curl -GET --cacert CertificateAuthorityCertificate.pem --cert client-cert.crt --key client-cert.key https://localhost:55000/pets
[ {
  "id" : 0,
  "name" : "some_string_value",
  "tag" : "some_string_value"
} ]%

Details about what that commands do you can find in official documentation of MockServer.

Project configuration

After creating a fresh Ktor project, you need to add http client and okhttp dependencies to it.

gradle.properties

ktor_version=1.6.2
okhttp_version=4.9.1

build.gradle.kts

val ktor_version: String by project
val okhttp_version: String by project

dependencies {
    implementation("io.ktor:ktor-client-core:$ktor_version")
    implementation("io.ktor:ktor-client-okhttp:$ktor_version")

    implementation("com.squareup.okhttp3:okhttp-tls:$okhttp_version")
}

When the job is done, outline the wireframe of the project in the Application.kt file:

fun main(args: Array<String>) = EngineMain.main(args)

@Suppress("unused")
fun Application.module() {
    //TODO: Decode certificates

    //TODO: Prepare certificates to handshake

    //TODO: Configure http client to work with client certificates

    //TODO: Implement requests proxying to the external system

}

Certificates preparing

Let’s do something really interesting. We have to convert certificates to java-objects the http client understands.

When you mess with production systems, you need to obtain certificates for your service from environment variables. It’s better from Maintainability point of view and decreases the chance of leaking certificates via source code. Also, I think that developers shouldn’t have an access to production certificates.

Begin with the conversion of CA certificate. We have only one certificate in the chain in our case, but frequently you will face a file contains several certificates like this:

-----BEGIN CERTIFICATE-----
<Base64 content>
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
<Base64 content>
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
<Base64 content>
-----END CERTIFICATE-----

So, it is better to immediately implement a method for splitting that chain into an array of X509Certificate objects

private val PEM_REGEX = Regex("""-----BEGIN ([!-,.-~ ]*)-----([^-]*)-----END \1-----""")

fun String.splitCertificatesPem(): Sequence<X509Certificate> {
    return PEM_REGEX.findAll(this).map { match ->
        match.groups[0]!!.value.decodeCertificatePem()
    }
}

Ok, let’s create three string constants with our certificates, convert them to X509Certificate-s and instantiate a special handshake object. ** I want to remember that storing certificates in code is just for educational purposes and you shouldn’t do this in production systems.**

private const val CACERT = "-----BEGIN CERTIFICATE-----\n" +
        "MIIDqDCCApCgAwIBAgIEPhwe6TANBgkqhkiG9w0BAQsFADBiMRswGQYDVQQDDBJ3\n" +
...
"Gm5MBedhPkXrLWmwuoMJd7tzARRHHT6PBH/ZGw==\n" +
        "-----END CERTIFICATE-----\n"

private const val CLIENT_CERT = "-----BEGIN CERTIFICATE-----\n" +
        "MIIFgjCCA2oCCQCeHxaTaEwG6TANBgkqhkiG9w0BAQsFADCBgjELMAkGA1UEBhMC\n" +
...
"HGh6/2TZ+8X8OeUlJ7DGIATIb8smzQ==\n" +
        "-----END CERTIFICATE-----\n"

private const val CLIENT_KEY = "-----BEGIN PRIVATE KEY-----\n" +
        "MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQC4lUH0MAdJkfUI\n" +
...
"7jrTiwcEtANdV45Zd4mAS6ctvNIJvg==\n" +
        "-----END PRIVATE KEY-----\n"

val caBundle = CACERT.splitCertificatesPem()
val heldCertificate = HeldCertificate.decode(CLIENT_CERT + CLIENT_KEY)

val handshakeCerts = HandshakeCertificates.Builder().apply {
    caBundle.forEach { addTrustedCertificate(it) }
    heldCertificate(heldCertificate)
}.build()

Http client configuration

To pass our certificates to http client, we need to configure sslSockerFactory which will be responsible for attaching these certificates to all requests to an external system thereby providing TLS authentication.

val ktorClient = HttpClient(OkHttp) {
    expectSuccess = false
    engine {
        config {
            sslSocketFactory(handshakeCerts.sslSocketFactory(), handshakeCerts.trustManager)
        }
    }
}

In default configuration, if Ktor is received a status other than 2xx, it will throw an exception. It’s unwanted behavior for us. Therefore, we need to switch off this thing by setting expectSuccess = false.

Proxying requests

The final step consists of redirecting the incoming customer’s request to an external system. To make things more interesting, let’s say that our external system also requires a key to access its API. Of course, our proxy is amazingly smart and can take this on itself.

intercept(ApplicationCallPipeline.Call) {
    val response = ktorClient.request<HttpResponse>("https://localhost:55000${call.request.uri}") {
        method = call.request.httpMethod

        headers {
            append("X-API-KEY", "102CAE97-17F9-4B08-82C0-AB7653F2EC1B")
        }

        if (call.request.httpMethod != HttpMethod.Get) {
            body = call.receiveText()
        }
    }

    call.respond(object : OutgoingContent.WriteChannelContent() {
        override val status: HttpStatusCode = response.status

        override suspend fun writeTo(channel: ByteWriteChannel) {
            response.content.copyAndClose(channel)
        }
    })
}

A demonstrated approach is quite simple and you definitely have to adapt it to your needs. But its core will remain the same. You need to intercept the customer’s request, spice it up with necessary components, redirect it to an external system and return the answer to the customer. The most significant part of this code is overriding HTTP status code to the one returned from the external system. If you don’t do it, your customer will receive 200 OK even the external system answer’s code was 4xx or 5xx.

Let’s check how it works

Now you can try to run the service and pray that nothing falls ;) Due to our smart proxy, we don’t need to add client certificates to requests anymore.

$ curl -GET http://localhost:8080/pets
[ {
  "id" : 0,
  "name" : "some_string_value",
  "tag" : "some_string_value"
} ]%

$ curl -v -POST -H "Content-Type: application/json" \
    -d '{"id": 1, "name": "test", "tag": "test-tag"}' \
    http://localhost:8080/pets
*   Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 8080 (#0)
> POST /pets HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.64.1
> Accept: */*
> Content-Type: application/json
> Content-Length: 44
>
* upload completely sent off: 44 out of 44 bytes
< HTTP/1.1 201 Created
< transfer-encoding: chunked
<
* Connection #0 to host localhost left intact
* Closing connection 0

Source code can be found here. Good luck!


See also