3 июля

Как мы в ZeroTech подружили Apple Safari и клиентские сертификаты с websocket-ами

Блог компании ZeroTechSafariNginxApacheLua
Статья будет полезна тем, кто:

  • знает, что такое Client Cert, и понимает для чего ему websocket-ы на мобильном Safari;
  • хотел бы публиковать web-сервисы ограниченному кругу лиц или только себе;
  • думает, что всё уже кем-то сделано, и хотел бы сделать мир немного удобнее и безопаснее.

История веб-сокетов началась примерно 8 лет назад. Ранее использовались методы вида долгих http-запросов (на самом деле ответов): браузер пользователя отправлял запрос на сервер и ждал, пока он ему что-то ответит, после ответа подключался вновь и ждал. Но потом появились веб-сокеты.



Несколько лет назад мы разработали собственную реализацию на чистом php, которая не умеет использовать запросы https, так как это канальный уровень. Не так давно практически все web-серверы научились проксировать запросы по https и поддерживать connection:upgrade.

Когда это случилось, веб-сокеты стали практически сервисом по умолчанию у SPA-приложений, ведь как удобно предоставлять пользователю контент по инициативе сервера (передать сообщение от другого пользователя или загрузить новую версию изображения, документа, презентации, которую сейчас кто-то ещё редактирует).

Хотя Сlient Сert появился уже довольно давно, он всё ещё остаётся мало поддерживаемым, так как создаёт массу проблем с попытками его обойти. И (возможно :slightly_smiling_face: ) поэтому IOS-браузеры (все, кроме Safari) не хотят его использовать и запрашивать у локального хранилища сертификатов. Сертификаты обладают массой преимуществ по сравнению с ключами login/pass или ssh или закрытием через firewall нужных портов. Но речь не об этом.

На IOS процедура установки сертификата довольно проста (не без специфики), но в общем делается по инструкциям, которых в сети очень много и которые доступны только для браузера Safari. К сожалению, Safari не умеет использовать Сlient Сert для веб-сокетов, но в интернете есть множество инструкций, как сделать такой сертификат, но на практике это недостижимо.



Чтобы разобраться в веб-сокетах, мы использовали следующий план: проблема/гипотеза/решение.

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

Гипотезы:

  1. Возможно настроить такое исключение для использования сертификатов (зная, что их не будет) к веб-сокетам внутренних/внешних проксируемых ресурсов.
  2. Для веб-сокетов можно сделать уникальное безопасное и защищаемое соединение с помощью временных сессий, которые генерируются при обычном (не веб-сокет) запросе браузера.
  3. Временные сессии можно реализовать с помощью одного proxy web-сервера (только встроенные модули и функции).
  4. Временные сессии-токены уже были реализованы в качестве готовых модулей apache.
  5. Временные сессии-токены можно реализовать, логически спроектировав структуру взаимодействий.

Видимое состояние после внедрения.

Цель работы: управление сервисами и инфраструктурой должно быть доступно с мобильного телефона на IOS без дополнительных программ (таких как VPN), унифицировано и безопасно.

Дополнительная цель: экономия времени и ресурсов/трафика телефона (некоторые сервисы без веб-сокетов генерируют лишние запросы) с ускорением отдачи контента на мобильном интернете.

Как проверить?

1. Открытие страниц:

— например, https://teamcity.yourdomain.com в мобильном браузере Safari (доступен также в десктопной версии) — вызывает успешное подключение к веб-сокетам.
— например, https://teamcity.yourdomain.com/admin/admin.html?item=diagnostics&tab=webS…— показывает ping/pong.
— например, https://rancher.yourdomain.com/p/c-84bnv:p-vkszd/workload/deployment:danidb:ph…-> viewlogs — показывает логи контейнера.

2. Или в консоли разработчика:



Проверка гипотез:

1. Возможно настроить такое исключение для использования сертификатов (зная, что их не будет) к веб-сокетам внутренних/внешних проксируемых ресурсов.

Тут было найдено 2 решения:

а) На уровне

<Location sock*> SSLVerifyClient optional </Location>
<Location /> SSLVerifyClient require </Location>

менять уровень доступа.

У данного метода возникли такие нюансы:

  • Проверка сертификата происходит после запроса к проксируемому ресурсу, то есть post request handshake. Это означает, что прокси сначала нагрузит, а потом отсечёт запрос к защищаемому сервису. Это плохо, но не критично;
  • В протоколе http2. Он ещё находится в draft-е, и производители браузеров не знают, как его реализовать #info about tls1.3 http2 post handshake (not working now) Implement RFC 8740 «Using TLS 1.3 with HTTP/2»;
  • Непонятно, как унифицировать эту обработку.

б) На базовом уровне разрешить ssl без сертификата.

SSLVerifyClient require => SSLVerifyClient optional, но это снижает уровень защиты proxy-сервера, так как такое соединение будет обработано без сертификата. Однако можно далее запретить доступ к проксируемым сервисам такой директивой:

RewriteEngine        on
RewriteCond     %{SSL:SSL_CLIENT_VERIFY} !=SUCCESS
RewriteRule     .? - [F]
ErrorDocument 403 "You need a client side certificate issued by CAcert to access this site"

Более подробная информация – в статье о ssl: Apache Server Client Certificate Authentication

Оба варианта были проверены, выбран вариант «б» за универсальность и совместимость с протоколом http2.

Для завершения проверки этой гипотезы потребовалось немало экспериментов с конфигурацией, были проверены конструкции:

if = require = rewrite

Получилась следующая базовая конструкция:
SSLVerifyClient optional
RewriteEngine on
RewriteCond %{SSL:SSL_CLIENT_VERIFY} !=SUCCESS
RewriteCond %{HTTP:Upgrade} !=websocket [NC]
RewriteRule     .? - [F]
#ErrorDocument 403 "You need a client side certificate issued by CAcert to access this site"

#websocket for safari without cert auth
<If "%{SSL:SSL_CLIENT_VERIFY} != 'SUCCESS'">
<If "%{HTTP:Upgrade} = 'websocket'">
...
    #замещаем авторизацию по владельцу сертификата на авторизацию по номеру протокола
    SSLUserName SSl_PROTOCOL
</If>
</If>


С учётом существующей авторизации по владельцу сертификата, но при отсутствующем сертификате пришлось добавить несуществующего владельца сертификата в виде одной из доступных переменных SSl_PROTOCOL (вместо SSL_CLIENT_S_DN_CN), подробнее в документации:

Apache Module mod_ssl



2. Для веб-сокетов можно сделать уникальное безопасное и защищаемое соединение с помощью временных сессий, которые генерируются при обычном (не веб-сокет) запросе браузера.

Исходя из предыдущего опыта нужно добавить дополнительную секцию в конфигурацию, чтобы при обычном (не веб-сокет) запросе готовить временные токены для веб-сокет соединений.

#подготовка передача себе Сookie через пользовательский браузер
<If "%{SSL:SSL_CLIENT_VERIFY} = 'SUCCESS'">
<If "%{HTTP:Upgrade} != 'websocket'">
Header set Set-Cookie "websocket-allowed=true; path=/; Max-Age=100"
</If>
</If>

#проверка Cookie для установления веб-сокет соединения
<source lang="javascript">
<If "%{SSL:SSL_CLIENT_VERIFY} != 'SUCCESS'">
<If "%{HTTP:Upgrade} = 'websocket'">
#check for exists cookie

#get and check
SetEnvIf Cookie "websocket-allowed=(.*)" env-var-name=$1

#or rewrite rule
RewriteCond %{HTTP_COOKIE} !^.*mycookie.*$

#or if
<If "%{HTTP_COOKIE} =~ /(^|; )cookie-name\s*=\s*some-val(;|$)/ >
</If

</If>
</If>

Проверка показала, что это работает. Возможно через пользовательский браузер передавать cебе Cookie.

3. Временные сессии можно реализовать с помощью одного proxy web-сервера (только встроенные модули и функции).

Как мы выяснили ранее, у Apache довольно много core-функциональности, которая позволяет создавать условные конструкции. Однако нам нужны средства защиты нашей информации, пока она находится в пользовательском браузере, поэтому устанавливаем, что и для чего хранить, и какие встроенные функции будем задействовать:

  • Нужен такой токен, который не поддаётся простому декодированию.
  • Нужен такой токен, в котором зашито устаревание и возможность проверки устаревания на сервере.
  • Нужен такой токен, который будет связан с владельцем сертификата.

Для этого нужна функция хеширования, соль и дата для устаревания токена. Исходя из документации Expressions in Apache HTTP Server у нас есть всё это из коробки sha1 и %{TIME}.

Получилась такая конструкция:
#нет сертификата, и обращение к websocket
<If "%{SSL:SSL_CLIENT_VERIFY} != 'SUCCESS'">
<If "%{HTTP:Upgrade} = 'websocket'">
    SetEnvIf Cookie "zt-cert-sha1=([^;]+)" zt-cert-sha1=$1
    SetEnvIf Cookie "zt-cert-uid=([^;]+)" zt-cert-uid=$1
    SetEnvIf Cookie "zt-cert-date=([^;]+)" zt-cert-date=$1

#только так можно работать с переменными, полученными в env-ах в этот момент времени, более они нигде не доступны для функции хеширования (по отдельности можно, но не вместе, да и ещё с хешированием)
    <RequireAll>
        Require expr %{sha1:salt1%{env:zt-cert-date}salt3%{env:zt-cert-uid}salt2} == %{env:zt-cert-sha1}
        Require expr %{env:zt-cert-sha1} =~ /^.{40}$/
    </RequireAll>
</If>
</If>

#есть сертификат, запрашивается не websocket
<If "%{SSL:SSL_CLIENT_VERIFY} = 'SUCCESS'">
<If "%{HTTP:Upgrade} != 'websocket'">
    SetEnvIf Cookie "zt-cert-sha1=([^;]+)" HAVE_zt-cert-sha1=$1

    SetEnv zt_cert "path=/; HttpOnly;Secure;SameSite=Strict"
#Новые куки ставятся, если старых нет
    Header add Set-Cookie "expr=zt-cert-sha1=%{sha1:salt1%{TIME}salt3%{SSL_CLIENT_S_DN_CN}salt2};%{env:zt_cert}" env=!HAVE_zt-cert-sha1
    Header add Set-Cookie "expr=zt-cert-uid=%{SSL_CLIENT_S_DN_CN};%{env:zt_cert}" env=!HAVE_zt-cert-sha1
    Header add Set-Cookie "expr=zt-cert-date=%{TIME};%{env:zt_cert}" env=!HAVE_zt-cert-sha1
</If>
</If>


Цель достигнута, но есть проблемы с серверным устареванием (можно использовать Cookie годичной давности), а значит токены, хоть и безопасны для внутреннего использования, но небезопасны для промышленного (массового).



4. Временные сессии-токены уже были реализованы в качестве готовых модулей Аpache.

С предыдущей итерации осталась одна существенная проблема — невозможность контролировать устаревание токена.

Ищем готовый модуль, который это делает, по словам: apache token json two factor auth


Да, готовые модули есть, но все привязаны к конкретным действиями и обладают артефактами в виде старта сессии и дополнительных Cookie. То есть не на время.
У нас ушло пять часов на поиск, который не дал конкретного результата.

5. Временные сессии-токены можно реализовать, логически спроектировав структуру взаимодействий.

Готовые модули слишком сложны, ведь нам нужно только пару функций.

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

То есть нельзя написать:

(%{env:zt-cert-date} + 30) > %{DATE}

Можно сравнивать только два числа.

При поиске обхода проблемы Safari нашлась интересная статья: Securing HomeAssistant with client certificates (works with Safari/iOS)
В ней описан пример кода на Lua для Nginx, и который, как оказалось, очень повторяет логику той части конфигурации, которую мы уже ранее реализовали, за исключением использования hmac-метода расстановки соли для хеширования (такого в Apache не нашлось).

Стало понятно, что Lua — это язык, с понятной логикой, возможно сделать что-то простенькое и для Apache:


Изучив разницу с Nginx и Apache:


И доступные функции от производителя языка Lua:
22.1 – Date and Time

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

Вот так выглядит простенький Lua-скрипт:
require 'apache2'

function handler(r)
    local fmt = '%Y%m%d%H%M%S'
    local timeout = 3600 -- 1 hour

    r.notes['zt-cert-timeout'] = timeout
    r.notes['zt-cert-date-next'] = os.date(fmt,os.time()+timeout)
    r.notes['zt-cert-date-halfnext'] = os.date(fmt,os.time()+ (timeout/2))
    r.notes['zt-cert-date-now'] = os.date(fmt,os.time())

    return apache2.OK
end


И вот так это всё работает в сумме, c оптимизацией числа Cookie и заменой токена при наступлении половинного времени до истечения старых Cookie (токена):
SSLVerifyClient optional

#LuaScope thread
#generate event variables zt-cert-date-next
LuaHookAccessChecker /usr/local/etc/apache24/sslincludes/websocket_token.lua handler early

#запрещаем без сертификата что-то ещё, кроме webscoket
RewriteEngine on
RewriteCond %{SSL:SSL_CLIENT_VERIFY} !=SUCCESS
RewriteCond %{HTTP:Upgrade} !=websocket [NC]
RewriteRule     .? - [F]
#ErrorDocument 403 "You need a client side certificate issued by CAcert to access this site"

#websocket for safari without certauth
<If "%{SSL:SSL_CLIENT_VERIFY} != 'SUCCESS'">
<If "%{HTTP:Upgrade} = 'websocket'">
    SetEnvIf Cookie "zt-cert=([^,;]+),([^,;]+),[^,;]+,([^,;]+)" zt-cert-sha1=$1 zt-cert-date=$2 zt-cert-uid=$3

    <RequireAll>
        Require expr %{sha1:salt1%{env:zt-cert-date}salt3%{env:zt-cert-uid}salt2} == %{env:zt-cert-sha1}
        Require expr %{env:zt-cert-sha1} =~ /^.{40}$/
        Require expr %{env:zt-cert-date} -ge %{env:zt-cert-date-now}
    </RequireAll>
   
    #замещаем авторизацию по владельцу сертификата на авторизацию по номеру протокола
    SSLUserName SSl_PROTOCOL
    SSLOptions -FakeBasicAuth
</If>
</If>

<If "%{SSL:SSL_CLIENT_VERIFY} = 'SUCCESS'">
<If "%{HTTP:Upgrade} != 'websocket'">
    SetEnvIf Cookie "zt-cert=([^,;]+),[^,;]+,([^,;]+)" HAVE_zt-cert-sha1=$1 HAVE_zt-cert-date-halfnow=$2
    SetEnvIfExpr "env('HAVE_zt-cert-date-halfnow') -ge %{TIME} && env('HAVE_zt-cert-sha1')=~/.{40}/" HAVE_zt-cert-sha1-found=1

    Define zt-cert "path=/;Max-Age=%{env:zt-cert-timeout};HttpOnly;Secure;SameSite=Strict"
    Define dates_user "%{env:zt-cert-date-next},%{env:zt-cert-date-halfnext},%{SSL_CLIENT_S_DN_CN}"
    Header set Set-Cookie "expr=zt-cert=%{sha1:salt1%{env:zt-cert-date-next}sal3%{SSL_CLIENT_S_DN_CN}salt2},${dates_user};${zt-cert}" env=!HAVE_zt-cert-sha1-found
</If>
</If>

SetEnvIfExpr "env('HAVE_zt-cert-date-halfnow') -ge %{TIME} && env('HAVE_zt-cert-sha1')=~/.{40}/" HAVE_zt-cert-sha1-found=1
работает,

а так работать не будет
SetEnvIfExpr "env('HAVE_zt-cert-date-halfnow') -ge  env('zt-cert-date-now') && env('HAVE_zt-cert-sha1')=~/.{40}/" HAVE_zt-cert-sha1-found=1 


Потому что LuaHookAccessChecker будет активирован только после проверок доступа исходя из этой информации от Nginx.



Cсылка на источник изображения.

Ещё один момент.

В целом неважно, в какой последовательности в конфигурации Аpache (вероятно и Nginx) написаны директивы, так как в итоге всё будет отсортировано исходя из очерёдности прохождения запроса от пользователя, который соответствует схеме для отработки Lua-скриптов.

Завершение:

Видимое состояние после внедрения (цель):
управление сервисами и инфраструктурой доступно с мобильного телефона на IOS без дополнительных программ (VPN), унифицировано и безопасно.

Цель достигнута, веб-сокеты работают и обладают не меньшим уровнем безопасности, чем сертификат.

Теги:websocketapple safariluanginxapache
Хабы: Блог компании ZeroTech Safari Nginx Apache Lua
+1
516 6
Комментировать
Лучшие публикации за сутки