Як ми в ZeroTech подружили Apple Safari та клієнтські сертифікати з websocket-ами

Стаття буде корисна тим, хто:

  • знає, що таке Client Cert, і розуміє навіщо йому websocket-и на мобільному Safari;
  • хотів би публікувати web-сервіси обмеженому колу осіб чи тільки собі;
  • думає, що все вже кимось зроблено, і хотів би зробити світ трохи зручнішим та безпечнішим.

Історія веб-сокетів розпочалася приблизно 8 років тому. Раніше використовувалися методи виду довгих http-запитів (насправді відповідей): браузер користувача надсилав запит на сервер і чекав, поки він щось відповість, після відповіді підключався знову і чекав. Але потім з'явилися веб-сокети.

Як ми в ZeroTech подружили Apple Safari та клієнтські сертифікати з websocket-ами

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

Коли це трапилося, веб-сокети стали практично сервісом за замовчуванням у SPA-додатків, адже як зручно надавати користувачеві контент з ініціативи сервера (надіслати повідомлення від іншого користувача або завантажити нову версію зображення, документа, презентації, яку зараз хтось ще редагує) .

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

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

Як ми в ZeroTech подружили Apple Safari та клієнтські сертифікати з websocket-ами

Щоб розібратися у веб-сокетах, ми використали наступний план: проблема/гіпотеза/рішення.

Проблема: відсутня підтримка веб-сокетів при проксуванні запитів до ресурсів, захищених клієнтським сертифікатом на мобільному браузері 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. Або в консолі розробника:

Як ми в ZeroTech подружили Apple Safari та клієнтські сертифікати з websocket-ами

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

1. Можна налаштувати такий виняток для використання сертифікатів (знаючи, що їх не буде) до веб-сокетів внутрішніх/зовнішніх ресурсів, що проксуються.

Тут було знайдено два рішення:

а) На рівні

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

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

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

  • Перевірка сертифіката відбувається після запиту до ресурсу, що проксується, тобто post request handshake. Це означає, що проксі спочатку навантажить, а потім відсіче запит до сервісу, що захищається. Це погано, але не критично;
  • У протоколі http2. Він ще знаходиться в draft-і, і виробники браузерів не знають, як його реалізувати #info about tls1.3 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

Як ми в ZeroTech подружили Apple Safari та клієнтські сертифікати з websocket-ами

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-names*=s*some-val(;|$)/ >
</If

</If>
</If>

Перевірка засвідчила, що це працює. Можливо через браузер користувача передавати собі 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 річної давності), а значить токени, хоч і безпечні для внутрішнього використання, але небезпечні для промислового (масового).

Як ми в ZeroTech подружили Apple Safari та клієнтські сертифікати з websocket-ами

4. Тимчасові сесії-токени вже були реалізовані як готові модулі Аpache.

З попередньої ітерації залишилася одна важлива проблема — неможливість контролювати старіння токена.

Шукаємо готовий модуль, який це робить, за словами: apache token

Так, готові модулі є, але всі прив'язані до конкретних дій і мають артефакти у вигляді старту сесії та додаткових Cookie. Тобто не на якийсь час.
Ми пішли п'ять годин на пошук, який не дав конкретного результату.

5. Тимчасові сесії-токени можна реалізувати, логічно спроектувавши структуру взаємодій.

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

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

Тобто не можна написати:

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

Можна порівнювати лише два числа.

Під час пошуку обходу проблеми Safari знайшлася цікава стаття: Securing HomeAssistant with client certificates (роботи з 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

І ось так це все працює в сумі з оптимізацією числа 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.

Як ми в ZeroTech подружили Apple Safari та клієнтські сертифікати з websocket-ами

Посилання на джерело зображення.

Ще один момент.

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

Завершення:

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

Мета досягнута, веб-сокети працюють і мають не менший рівень безпеки, ніж сертифікат.

Як ми в ZeroTech подружили Apple Safari та клієнтські сертифікати з websocket-ами

Джерело: habr.com

Додати коментар або відгук