Pull to refresh
Онлайн-кинотеатр Иви
Мы открываем для людей многообразие мира кино

Реализуем безопасный VPN-протокол

Reading time12 min
Views48K
Снова тема VPN, конфиденциальной передачи данных, безопасности и криптографии. Сейчас, в эру пост-Сноудена, стало модно клепать безопасные, уважающие приватность, невзламываемые, защищённые от спецслужб и цензуры программы. Однако эта статья – не очередной маркетинговый пиар, а, скорее, демонстрация того, как применяются криптографические примитивы и на что стоит обращать внимание при разработке подобного программного обеспечения.



Результатом этой работы предполагается создание работающего клиент-сервера, пригодного для review разработчиками (то есть немного кода на высокоуровневом языке), достаточно производительного, чтобы использоваться в промышленных условиях, имеющего высокий порог безопасности: GoVPN.

Чем не устраивает уже масса известных имеющихся решений в виде SSH (он может работать с TUN/TAP устройствами и быть использованным для VPN), TLS, OpenVPN или IPsec? Сложностью как протокола, так и кода. А отсюда и сложность review, под вопросом безопасность. Зависимость от организаций США, диктующих, какие алгоритмы могут быть использованы. Только SSH из коробки предлагает такие быстрые, безопасные, независимые от спецслужб алгоритмы как ChaCha20, Poly1305 и Curve25519. TLS-библиотеки и протокол себя массово показали с плохой стороны из-за своей сложности. OpenVPN, когда не использует PSK (pre-shared key – заранее известный обеими сторонам ключ), то тоже зависит от TLS со всеми вытекающими.

Транспортный протокол


Для решения задачи создания просто VPN современные ОС предлагают такую удобную штуку, как TUN/TAP: виртуальные сетевые интерфейсы. Очень просто написать клиент-сервер приложение, читающее frame-ы из интерфейса, заворачивающие их в UDP-пакеты и отсылающие удалённой стороне. Но нам хочется обеспечить конфиденциальность передаваемых данных, аутентификацию обеими сторон соединения (убедиться, что они выдают себя за того, кого мы и ожидаем).

Весь протокол глобально состоит из двух частей: процесса рукопожатия (handshake) и транспорта. Рукопожатие будем пока считать чёрным ящиком на выходе из которого мы получим: IP-адрес с портом противоположной стороны для отправки UDP-пакетов и общий симметричный ключ шифрования. С рукопожатия начинается любое общение, и аутентификацию сторон достаточно произвести только на этом этапе.

Транспорт отвечает за шифрованную (обеспечивающую конфиденциальность полезной нагрузки) передачу сообщений и их приёмку с дешифрованием. В качестве алгоритма/функции шифрования выбираем Salsa20 (или вариант этого алгоритма ChaCha20). На данный момент он имеет очень хорошую криптографическую безопасность (фатальных недостатков в криптоанализах не выявлено), высочайшую производительность и простоту реализации в коде. Это потоковый шифр: то есть можно считать, что результат его работы – это псевдослучайная последовательность байт, которые необходимо XOR-ить с данными, требующими шифрования.

С потоковыми шифрами стоит быть очень аккуратным. В отличии от блочных, их гораздо сложнее анализировать (поэтому популярные сильные шифры сейчас все блочные), и если хотя бы где-то ключ (соответственно и псевдослучайный выход этой функции) использовался дважды, то это моментально приводит к возможности лёгкого дешифрования ранее перехваченных сообщений этого ключа. Однако есть и приятные простые стороны: размер открытого текста равен по длине размеру зашифрованного, не надо размышлять о дополнениях (до кратности размера шифроблока), о разнообразных режимах шифрования (CBC, ECB, CTR и прочие).

Salsa20 на вход принимает кроме ключа с данными ещё и nonce: число, которое должно быть использовано только один раз для заданного ключа. Использование дважды фатально. В качестве nonce мы используем простой, постоянно инкрементирующийся счётчик. Каждый приходящий пакет его увеличивает. Это требует хранить состояние транспортного соединения на обеих сторонах. В качестве альтернативы можно было бы использовать генератор псевдослучайных чисел (PRNG) и обращаться к нему каждый раз, но это гораздо медленнее и найти качественный (который за время работы шифра с одним ключом гарантированно не выплюнет одно и то же число) PRNG проблематично. Принимающая сторона для дешифрования должна также знать этот nonce. Если бы пакеты не терялись и доходили гарантированно в заданном порядке, то достаточно просто хранить состояние nonce счётчика противоположной стороны и инкрементировать его с приходом пакета. Но UDP-пакеты теряются и поэтому к каждому пакету в начале мы прописываем значение nonce.

Это уже работоспособный протокол, обеспечивающий конфиденциальность передачи данных, однако, он уязвим к атакам, присущим всем потоковым шифрам: шифротекст можно осознанно изменить и сделать требуемую подмену открытого текста после дешифрования. Для их предотвращения необходимо аутентифицировать передаваемые пакеты данных: подтверждать и убеждаться в том, что это именно тот набор данных, который и был передан. Как правило, применяют коды аутентичности сообщений (MAC – message authentication code). MAC на входе принимает ключ и данные, которые необходимо аутентифицировать, а на выходе – так называемый тэг, прикрепляемый к сообщению при передаче.

В качестве MAC мы выбираем Poly1305. Как и Salsa20 от того же автора, он имеет высочайший потолок безопасности, производительность и простоту реализации. Poly1305 имеет разный API в разных библиотеках, но в простейшем случае можно предполагать, что его ключи одноразовые и один ключ используется для аутентификации только одного сообщения. В качестве ключа можно иметь какую-то секретную (известную только двум сторонам) часть с прикреплённым к ней счётчиком. Но уже нормальной практикой стало генерирование ключа аутентификации для каждого сообщения на основе выхода Salsa20 PRNG этого пакета. То есть в момент шифрования пакета, мы берём первые 256 бит выхода Salsa20 и используем их в качестве ключа аутентификации Poly1305. Для шифрования их, конечно же, уже не используем. Так как внутреннее состояние Salsa20 это 512 бит, то на всякий пожарный игнорируем и следующие 256 бит выхода. Да, это потеря производительности, но незначительная, и дающая простой (а значит, потенциально более надёжный в плане безопасности) код. Это уже вовсю применяемая практика в SSH и TLS-протоколах.

Таким образом мы получаем аутентифицированный режим шифрования, который действительно может применяться на практике. Единственное, что чуть-чуть дополним, так это обфусцирование/scrambling передаваемого nonce. С точки зрения безопасности нареканий к нему нет, но мы смотрим на то, чтобы в идеале наш протокол был неотличим от шума, от случайных данных, чтобы усложнить жизнь всё достающим DPI-системам анализа трафика.

Nonce это 64 бит блок данных. В идеале можно просто зашифровать его, превратив в подобие шума. У нас уже есть Salsa20-шифр, однако, он представляет собой PRF-функцию (pseudorandom function – псевдослучайная функция), а нам хочется иметь PRP (pseudorandom permutation – псевдослучайные перестановки): просто перемешать биты. Сделать PRP из PRF можно и Luby-Rackoff доказали это. Однако мы всё же введём ещё один примитив: функцию блочного шифра XTEA. Выбор на неё пал из-за простоты реализации, чтобы не писать самостоятельно сеть Фейстеля из Salsa20. XTEA не самый быстрый, не самый безопасный (хотя имеет высокий порог), но это всё не так критично из-за того, что вызываться он будет ровно один раз при отправке пакета. Так как nonce не повторяется, то задумываться о режимах шифрования, векторах инициализации и подобном не нужно. Так как nonce кратен размеру шифроблока, то и дополнения не нужно.

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

Остаётся последнее, о чём необходимо помнить: что делать с атакой перепроигрывания (replay attack). Суть её в том, что никто не запрещает перехватить пакет и отправить его повторно. Вариантов решения много: запоминать пакеты и смотреть, не встречаются ли дубляжи (это требует память), смотреть на временные штампы (это требует хорошую синхронизацию часов), либо, в нашем случае, просто запомнить последний полученный nonce противоположной стороны. Все пакеты с nonce ниже последнего будут игнорироваться. К сожалению, из-за природы UDP-пакеты в процессе передачи могут быть переупорядочены и поэтому будут происходить их потери при таком подходе. В нашей реализации (это уже протокола, как такового, не касается) мы разрешаем опционально иметь некое окно допустимых вариаций значений nonce-ов: компромисс между абсолютной защитой от повторных пакетов и относительно небольшим окном, когда можно осуществить подобную атаку на практике.

Получившийся транспортный протокол выглядит так:



Рукопожатие


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

Мы не используем и не хотим использовать PSK для шифрования: долгоживущий ключ это, конечно, просто, но в случае его утраты, компрометации мы сможем дешифровать весь перехваченный ранее трафик. Хочется иметь такое свойство, как совершенную прямую секретность. Для этого мы каждый раз, для каждой новой сессии, при каждом соединении генерируем новые ключи. Они временные, сессионные и находятся только в памяти программ и забываются при их выключении. Кроме того, существуют ограничения на максимальный размер данных, которые стоит шифровать одним ключом. Для Salsa20 эта граница довольно большая, но для спокойствия стоит о ней помнить и, например, производить обмен ключами (рукопожатие с нуля) каждые N гигабайт шифрованного трафика.

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

В нашем протоколе будем использовать Curve25519: опять же, один из самых простых с точки зрения реализации в программе, обладающий отличной (возможно на данный момент и непревзойдённой) криптографической безопасностью, потрясающей производительностью. Алгоритм основан на работе с эллиптическими кривыми и поэтому имеет компактные размеры публичного ключа: 256 бит. Приватный ключ генерируется просто взяв 256 бит данных из PRNG операционной системы. Curve25519 эта функция, грубо говоря, позволяющая перемножать точки на кривой. Публичный ключ получается перемножением точки, являющейся приватным ключом, с вшитой в код константной. Общий секрет получается перемножением точки приватного ключа одной стороны с точкой публичного ключа противоположной.

Передав по одному пакету с публичным ключом от каждой стороны, мы в состоянии уже установить общий ключ, общий секрет. Однако мы не аутентифицируем стороны: мы не знаем, с кем общаемся. Очень часто в современных протоколах делают обмен ключами, потом их используют для шифрования канала связи, и уже по нему используют отдельный протокол аутентификации: например, передавая пароли или используя CHAP, или применяя асимметричную криптографию типа RSA. Во-первых, это означает большое количество round-trip-пакетов по сети, что не очень быстро. Во-вторых, если используется асимметричная криптография, то это ресурсоёмко и сложно.

В нашем протоколе мы используем общий ключ аутентификации сторон. Фактически пароль, но только высокоэнтропийный. Если задача стояла бы в обязательной аутентификации только клиента, то тогда поверх шифрованного сессионным ключом каналу можно было бы просто послать этот пароль и сравнить с тем, что в базе данных. Однако если будет сервер злоумышленника, то он узнает пароль. Можно использовать CHAP-протокол: он прост, быстр, но сервер злоумышленника узнает хэш от пароля/ключа. С одной стороны, не велика потеря, но это даёт возможность атаки уже на хэш-функцию.

Наилучшим выбором для нас является EKE: Encrypted Key Exchange – шифрованный обмен ключами. Это подвид семейства протоколов PAKE: password authenticated key exchange – аутентифицируемый паролями обмен ключами. Суть протоколов заключается в том, что именно в момент обмена ключами происходит аутентификация одной или двух сторон. В простейшем случае каждый из двух пакетов DH симметрично шифруется, в качестве ключа используется общий известный друг другу пароль. Если пароль не совпадает, то стороны получают некорректно дешифрованные публичные ключи, которые не дадут при перемножении совпадающий общий ключ.

При этом имеется такое свойство, как нулевое неразглашение (zero-knowledge proof), при котором ни бита информации о PSK-ключе не будет известно злоумышленнику: он получает зашифрованные данные (шум) и единственное, что он может предпринять, так это попытаться дешифровать, но у него нет возможности узнать правильно ли он нашёл публичный ключ (дешифровал ли его), так как он тоже является шумом. Именно это свойство решающее в нашем выборе DH-EKE протокола и им не обладают популярные SSH, TLS и прочие – у всех у них возможны атаки либо на асимметричную криптографию, либо на хэш-функции, либо на симметричные шифры.

Для шифрования используется всё тот же Salsa20. Так как на входе у него присутствует nonce, который никогда не должен повторяться, то каждый раз мы генерируем для него случайный nonce R и посылаем вместе с шифрованным публичным ключом DH со стороны клиента.

R, enc(PSK, R, ClientDHPubKey) --> Server

Сервер, зная общий ключ аутентификации, способен корректно расшифровать сообщение, сразу же вычислить общий секрет.

R, enc(PSK, R, ClientDHPubKey) --> Server
enc(PSK, R+1, ServerDHPubKey)  --> Client

Для аутентификации сторон мы передадим случайные числа (RS со стороны сервера) и ожидаем их получить снова в ответе после перешифрования:

R, enc(PSK, R, ClientDHPubKey)               --> Server
enc(PSK, R+1, ServerDHPubKey), enc(K, R, RS) --> Client
enc(K, R+1, RS)                              --> Server

Клиент после получения публичного ключа сервера способен вычислить общий ключ K, соответственно корректно дешифровать RS, зашифровать его и отправить на сервер. Сервер сравнивает RS с тем, что отправил (состояние должен сохранить в памяти) и тем самым убедиться, что клиент действительно знает PSK. Для аутентификации сервера делается аналогичное действие со случайным числом от клиента RC:

R, enc(PSK, R, ClientDHPubKey)               --> Server
enc(PSK, R+1, ServerDHPubKey), enc(K, R, RS) --> Client
enc(K, R+1, RS + RC)                         --> Server
enc(K, 0, RC)                                --> Client

В последнем случае в качестве nonce мы используем ноль, а можно R+2 или ещё что-нибудь подобное. Не принципиально: лишь бы не повторялось при использовании одного ключа, а ключ K уже уникальный для каждой сессии. Клиент, получив от сервера корректно дешифрованный RC убеждается, что он с сервером действительно имеют общий ключ и сервер знает PSK.

Общий ключ K может обладать не лучшей энтропией. Curve25519 всё же имеет не все 256 бит задействованными, соответственно, его криптографическая стойкость чуть меньше симметричного аналога 128 бит порога. Кроме того, недобропорядочная сторона может использовать специально созданные публичные ключи, которые при перемножении могут давать слабые ключи K: в теории такое не исключено, хотя на практике для Curve25519 не замечено. Но всё же параноидальные настроения можно будет удовлетворить, отдельно генерируя каждой стороной качественный высокоэнтропийный ключ и передавая его в пакетах, шифруемых K-ключом. Результирующий общий ключ, который уже и будет использоваться в транспортном протоколе, получается XOR-ом частей, созданных клиентом и сервером. Если какая-либо сторона будет недобропорядочна, то всё-равно достаточно одной высокоэнтропийной части ключа для хорошего ключа. На итоговой схеме ниже часть ключа клиента обозначена как SC, а сервера – SS.

И конечным штрихом будет отправка идентификатора клиента. Эта часть не связана с безопасностью протокола: она исключительно для удобства администрирования. Когда у нас несколько клиентов, то нужно как-то определять, какие ключи PSK необходимо использовать. Можно разграничивать клиенты и серверы по портам, но мы решили отправлять идентификатор явно. Идентификатор – это не секретная информация, но отправляя её в явном виде, мы не имеем анонимность и даём возможность применять DPI и чётко видеть, какой именно пользователь устанавливает соединение.

На данный момент идентификатор посылается в виде зашифрованного R (который уже присутствует в первоначальном пакете рукопожатия от пользователя), где в качестве ключа используется идентификатор клиента. В качестве шифра используется XTEA. Так как R каждый раз разный, то опять же, задумываться о векторах инициализации, режимах шифрования, не надо. Сервер не может сразу же детерминировано определять, каким ключом было произведено шифрование и он просто перебирает все известные ему идентификаторы клиентов и находит того, у кого результат дешифрования совпадает с R. Симметричный шифр – это быстрая операция и поэтому расходы тут несущественны, по факту на порядки меньшие, чем работа DH. В данном случае мы, конечно, предоставляем злоумышленнику и открытый текст и шифротекст, но криптоанализы XTEA показывают, что беспокоиться не о чем. В худшем случае злоумышленник узнает идентификатор клиента, но не PSK-ключ.

Итоговый вариант рукопожатия выглядит так:



Что ещё стоит сделать или исправить?


На данный момент пакеты рукопожатия имеют явную метку (оканчивается на нулевые байты): сервер сразу же может понять, что это не транспортный пакет. Если мы хотим противостоять DPI, то нужно убрать эту метку и сделать handshake-пакеты неотличимыми от шума. Исправлено в версии 2.3

Протокол на этапе рукопожатия и генерирования PSK-ключей зависит от качественного PRNG. Если PRNG предсказуем, слаб, то говорить о безопасности не приходится. Запускать программное обеспечение под закрытой проприетарной операционной системой и употреблять слово «безопасность» бессмысленно. Популярные Microsoft Windows и Apple OS X известно, что имеют непригодные для криптографии источники энтропии и PRNG. Можно было бы встроить собственный PRNG, например, на основе Fortuna, но это лишь отсрочка неизбежной утечки ключей, так как ОС всё-равно имеет полный доступ к памяти программ и никто не гарантирует, что информация из них не используется для лазеек. Исправлено в версии 3.4: можно использовать сторонние EGD-совместимые PRNG источники.

Протокол обеспечивает конфиденциальность, аутентичность полезной нагрузки, двустороннюю zero-knowledge аутентификацию сторон, но данные о размерах и времени возникновения пакетов утекают в сеть. Решить это можно их фрагментацией и добавлением «шума»: посторонних, не связанных с настоящим трафиком пакетов. Исправлено в версии 3.0

Читайте также продолжение статьи Реализуем ещё более безопасный VPN-протокол!

Всего доброго, не переключайтесь!

© Сергей Матвеев, Python и Go разработчик ivi.ru

Наши предыдущие публикации:
» Лишние элементы или как мы балансируем между серверами
» Blowfish на страже ivi
» Неперсонализированные рекомендации: метод ассоциаций
» По городам и весям или как мы балансируем между узлами CDN
» I am Groot. Делаем свою аналитику на событиях
» Все на одного или как мы построили CDN
Tags:
Hubs:
Total votes 47: ↑46 and ↓1+45
Comments56

Articles

Information

Website
www.ivi.ru
Registered
Founded
Employees
501–1,000 employees