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:
- Integration will have to be done in each service
- 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:
- Ktor + netty as a glue for everything
- okhttp as an engine for Ktor http client
- okhttp-tls to work with certificates
- MockServer will act as an external overprotected system
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.
|
|
If everything was successful you will see two files:
client-cert.crt
— certificateclient-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.
|
|
But you can not pass client certificate and key only. You also need to pass CA certificate with the request.
|
|
Excellent! Handshake has been passed successfully. Now we need to force the server to return something more useful
than 404 Not Found
.
|
|
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!