Создаем reverse proxy с mTLS на Ktor - (не)Уникальный опыт

Создаем reverse proxy с mTLS на Ktor


Если вы любите интегрироваться с внешними системами, которые придерживаются высоких стандартов безопасности, то наверняка сталкивались с такой штукой как Mutual TLS (aka взаимная TLS аутентификация или mTLS). Ничего особенно сложного в такой интеграции нет. За исключением двух нюансов:

  1. Интеграцию придется делать в каждом сервисе
  2. В каждый сервис нужно положить клиентские сертификаты для прохожения хэндшейка mTLS

И если с первым нюансом можно как-то жить и мириться, то второй уже вызывает гораздо больше проблем и приводит к грустным лицам вашей команды инфобеза. Чтобы сделать их снова счастливыми, а заодно ликвидировать потребность писать один и тот же код в каждом сервисе — можно сделать умный reverse proxy, который возьмет на себя все тяготы и лишения связанные с хэндшейком и возможно какой-то другой дополнительной логикой, которую накрутили авторы внешней системы. Делать это хозяйство будем на Ktor, потому что почему бы и нет ;)

Предварительные ласки

Чтобы избежать любых неожиданностей, сразу обозначу стек технологий на котором будем все делать:

  • Ktor + netty в качестве клея для всего
  • okhttp как движок для Ktor-овского http клиента
  • okhttp-tls для операций с сертификатами
  • MockServer будет выступать в роли внешней, сверхзащищенной системы

Также предполагается, что у вас уже установлен Docker. Все описываемое далее можно сделать и без него, но с ним будет проще.

Настройка MockServer-а

Для того чтобы все заработало, нужно сгенерировать клиентский сертификат, про который будет знать сервер и с которым в него будет ходить клиент. Проще всего это сделать из консоли с помощью openssl:

 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

Если все прошло успешно, то должно появиться два файла:

  • client-cert.crt — сертификат
  • client-cert.key — секретный ключ

Теперь все готово к запуску MockServer с поддержкой mTLS аутентификации. Будьте внимательны с указанием путей к клиентским сертификатам на вашей машине.

$ 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

Если сейчас просто дернуть GET /, то ничего хорошего из этого не выйдет, т.к. наш сервер будет ожидать хэндшейка с клиентскими сертификатами.

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?)

Но просто так передать сертификат и ключ не выйдет. Нужен еще CA сертификат, который нужно встроить в “цепочку доверия” при выполнении запроса. MockServer использует свой собственный CA сертификат, который нужно скачать и добавить к запросу.

 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

Отлично, хэндшейк прошел успешно. Осталось заставить сервер возвращать что-то более осознанное чем 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"
} ]%

Подробности о произошедшем выше можно взять в официальной документации к MockServer.

Настройка проекта

После создания свежего Ktor-проекта в него нужно добавить http клиент и okhttp.

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")
}

И набросать каркас проекта в файле Application.kt:

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

@Suppress("unused")
fun Application.module() {
    //TODO: Преобразовать сертификаты

    //TODO: Подготовить сертификаты для хэндшейка

    //TODO: Настроить http клиент на работу с клиентскими сертификатами

    //TODO: Реализовать проксирование запросов во внешнюю систему
}

Подготовка сертификатов

Теперь начинается самое интересное. Нам нужно преобразовать сертификаты в java-объекты, которые понимает http клиент.

В настоящих системах передавать сертификаты сервису необходимо из переменных окружений. Во-первых это более правильно с точки зрения Maintainability, а во-вторых уменьшает шанс утечки сертификатов через код. В идеале разработчики такого proxy вообще не должны иметь доступа к клиентским production-сертификатам.

Начнем с преобразования CA сертификата. В нашем случае он один, но часто их бывает несколько и они расположены друг за другом в одном файле:

-----BEGIN CERTIFICATE-----
<содержимое сертификата>
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
<содержимое сертификата>
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
<содержимое сертификата>
-----END CERTIFICATE-----

Поэтому лучше сразу сделать метод, который распарсит такой файл и преобразует его в массив объектов X509Certificate .

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()
    }
}

С утилитами разобрались. Теперь сделаем три константы, которые будут хранить наши сертификаты в текстовом виде. Это сделано для простоты учебного примера и в реальных системах так делать нельзя. Потом преобразуем эти константы в сертификаты и создадим специальный объект для хэндшейка.

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 клиента

Чтобы http клиент мог использовать наши сертификаты нужно настроить sslSocketFactory, которая будет цеплять нужные сертификаты ко всем запросам во внешнюю систему обеспечивая тем самым взаимную аутентификацию по TLS.

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

По умолчанию Ktor клиент будет кидать исключение, если получил в ответ что-то отличное от 2xx. В нашем случае это поведение крайне нежелательно, поэтому нужно выключить эту штуку установив expectSuccess = false. При желании можно еще добавить логирование запросов. Пусть это будет вашим домашним заданием.

Проксирование запросов

Осталось сделать самую малость: перенаправить запрос пришедший от клиента во внешнюю систему. Чтобы все было еще интереснее, предположим, что эта система помимо mTLS аутентификации также требует ключ для доступа к своему API. А мы, поскольку пишем умный прокси, возьмем на себя добавление этого ключа к запросам.

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)
        }
    })
}

Показанный вариант довольно простой и его точно придется доработать под ваши нужды, но суть останется той же самой. Нужно перехватить вызов, который пришел от клиента, добавить туда все недостающие компоненты, послать запрос во внешнюю систему и вернуть клиенту полученный ответ. Важным здесь является установка такого же кода ответа, каким ответила внешняя система. Если этого не сделать, то даже при получении ошибок 4xx и 5xx наш прокси будет радостно возвращать 200 OK. Также нужно не забыть установить именно тот порт, на котором крутится MockServer. В примере это 55000, но у вас наверняка будет другой.

Проверка работоспособности

На текущий момент, если вы все сделали правильно и у вас ничего не упало при запуске, то можно начинать стрелять запросы в наш прокси чтобы убедиться, что все работает. Теперь к запросом можно не добавлять никакие сертификаты, прокси все будет делать за нас.

$ 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

Исходники можно взять здесь. Удачи!


Смотрите также