
Привет, хабр! В этой статье на примере простого чата реализуем E2E шифрование.
Продолжаем нашу серию статей по сетевой разработке на Go. В прошлой статье мы писали простейший TCP-чат. Наши сообщения в открытую гуляли в сети, что делало их простой мишенью для компрометации. В этой статье добавим в наш чат шифрование для безопасного обмена данными.
Немного теории
Эта статья нацелена на простое понимание и применение на практике. На Хабре полно материалов, которые отлично и досконально разбирают все математические аспекты криптографии. Мы же сосредоточимся на инженерной реализации, но для старта нам необходимо зафиксировать три базовых понятия:
Симметричное шифрование — это метод, при котором для шифрования и расшифровки данных используется один и тот же секретный ключ. Он работает быстро и идеально подходит для защиты больших объемов данных. Главная проблема: как безопасно передать этот ключ собеседнику через интернет, чтобы его никто не перехватил?
Симметричное шифрование — это метод, при котором для шифрования и расшифровки данных используется один и тот же секретный ключ. Он работает быстро и идеально подходит для защиты больших объемов данных. Главная проблема: как безопасно передать этот ключ собеседнику через интернет, чтобы его никто не перехватил?
Асимметричное шифрование — это метод, который решает проблему передачи ключей. Здесь у каждого пользователя есть пара из двух ключей: публичный, который используется только для шифрования и открыто передается по сети, приватный, который нужен для дешифрования и должен храниться в тайне.
Асимметричное шифрование — это метод, который решает проблему передачи ключей. Здесь у каждого пользователя есть пара из двух ключей: публичный, который используется только для шифрования и открыто передается по сети, приватный, который нужен для дешифрования и должен храниться в тайне.
Шифрование на эллиптических кривых (ECC / ECDH) — это современный подвид асимметричной криптографии, основанный на сложной математике точек на изогнутой кривой. У него есть два киллер-фичи: он работает в разы быстрее старого RSA, а его ключи гораздо короче при той же степени защиты (всего 32 байта для X25519 против огромных 4096 бит у RSA). Мы используем протокол ECDH (Elliptic Curve Diffie-Hellman): он позволяет двум людям, зная публичные ключи друг друга, за одну математическую операцию вычислить один и тот же общий секрет, вообще не передавая его по сети.
Шифрование на эллиптических кривых (ECC / ECDH) — это современный подвид асимметричной криптографии, основанный на сложной математике точек на изогнутой кривой. У него есть два киллер-фичи: он работает в разы быстрее старого RSA, а его ключи гораздо короче при той же степени защиты (всего 32 байта для X25519 против огромных 4096 бит у RSA). Мы используем протокол ECDH (Elliptic Curve Diffie-Hellman): он позволяет двум людям, зная публичные ключи друг друга, за одну математическую операцию вычислить один и тот же общий секрет, вообще не передавая его по сети.
Весь процесс простым языком:
Клиент генерирует AES (Advanced Encryption Standard) ключ (для симметричного шифрования), приватный и публичный ECDH (Elliptic Curve Diffie-Hellman) ключи.
Клиент генерирует AES (Advanced Encryption Standard) ключ (для симметричного шифрования), приватный и публичный ECDH (Elliptic Curve Diffie-Hellman) ключи.
Происходит обмен публичными ключами между пользователями, каждый из своего приватного и чужого пабличного генерирует общий мастер-ключ.
Происходит обмен публичными ключами между пользователями, каждый из своего приватного и чужого пабличного генерирует общий мастер-ключ.
С помощью полученного мастер-ключа каждый шифрует свой AES ключ и передает собеседнику.
С помощью полученного мастер-ключа каждый шифрует свой AES ключ и передает собеседнику.
После обмена симметричными ключами каждый расшифровывает ключ собеседника.
После обмена симметричными ключами каждый расшифровывает ключ собеседника.
При отправке сообщения пользователь шифрует его своим симметричным ключем, который уже есть у собеседника. Получателю лишь остается расшифровать сообщение тем же ключем.
При отправке сообщения пользователь шифрует его своим симметричным ключем, который уже есть у собеседника. Получателю лишь остается расшифровать сообщение тем же ключем.
Реализуем клиент
В прошлый раз мы ограничились реализацией серверной стороны, а в качестве клиента взяли telnet. Сейчас же нам нужно проработать и логику клиента.
Делаем структуру клиента:
Подключаемся к хосту:
Теперь дополним структуру нашего клиента для хранения собственных ключей и ключей собеседников из чата:
Деалем хэш-тиблицу, в которой ключ — адрес пользователя, значение мастер-ключ для обмена симметричными ключами между юзерами и сам симетричный ключ пользователя.
Далее прорабатываем все криптографические функции.
Генерация общего мастер-ключа:
Шифруем наш симметричный ключ:
Зачем нужен GCM?
Режим GCM — это аутентифицированное шифрование (AEAD).
Алгоритм делает две вещи:
Шифрует aesKey с использованием ключа и твоего случайного nonce.
Шифрует aesKey с использованием ключа и твоего случайного nonce.
Берет получившийся шифротекст, берет этот же самый nonce, смешивает их с секретным ключом и создает в самом конце 16-байтный тег подлинности (Auth Tag).
Берет получившийся шифротекст, берет этот же самый nonce, смешивает их с секретным ключом и создает в самом конце 16-байтный тег подлинности (Auth Tag).
Получается структура: [ 12 байт Nonce ] + [ Зашифрованный ключ ] + [ 16 байт Тега ]
Таким образом зашифрованный текст связывается с тегом подлинности, защищая пакет от модификации в пути.
Дешифруем переданный нам ключ собеседника:
Шифруем и дешифруем сообщение:
Чтобы реализовать контракт обмена между пользователями нам понадобятся следующие структуры:
TypeAuth ("AUTH") — отправляется клиентом при первом подключении; содержит его публичный ECDH-ключ для регистрации на сервере.
TypeAuth ("AUTH") — отправляется клиентом при первом подключении; содержит его публичный ECDH-ключ для регистрации на сервере.
TypePeerList ("PEER_LIST") — отправляется сервером лично новичку в ответ на авторизацию; содержит список адресов и публичных ключей всех участников, которые уже находятся в чате.
TypePeerList ("PEER_LIST") — отправляется сервером лично новичку в ответ на авторизацию; содержит список адресов и публичных ключей всех участников, которые уже находятся в чате.
TypeNewPeer ("NEW_PEER") — рассылается сервером (бродкастом) всем «старичкам» в чате; уведомляет их о подключении нового пользователя и передает его публичный ключ.
TypeNewPeer ("NEW_PEER") — рассылается сервером (бродкастом) всем «старичкам» в чате; уведомляет их о подключении нового пользователя и передает его публичный ключ.
TypeGetPeerList ("GET_PEER_LIST") — сервисный запрос от клиента к серверу с требованием принудительно обновить список участников (используется как фолбек, если таблица топологии не синхронизирована).
TypeGetPeerList ("GET_PEER_LIST") — сервисный запрос от клиента к серверу с требованием принудительно обновить список участников (используется как фолбек, если таблица топологии не синхронизирована).
TypeSenderKey ("SENDER_KEY") — отправляется между клиентами через сервер; содержит собственный симметричный AES-ключ отправителя, зашифрованный общим секретом ECDH для конкретного получателя.
TypeSenderKey ("SENDER_KEY") — отправляется между клиентами через сервер; содержит собственный симметричный AES-ключ отправителя, зашифрованный общим секретом ECDH для конкретного получателя.
TypeChatMsg ("CHAT_MSG") — пакет с обычным текстовым сообщением; само содержимое (Message) зашифровано AES-ключом отправителя, а сервер просто пересылает его остальным участникам.
TypeChatMsg ("CHAT_MSG") — пакет с обычным текстовым сообщением; само содержимое (Message) зашифровано AES-ключом отправителя, а сервер просто пересылает его остальным участникам.
Системный пакет для генерации пакетов:
Генерируем 3 ключа клиента:
Возвращаемся к логике клиента:
Читаем поток пакетов, которые передает сервер.
Разберем обработку полученного пакета по каждому кейсу отдельно:
Сервер сигнализирует, что подключился новый пользователь. Генерируем мастер-ключ с помощью переданного публичного ключа нового юзера и собственного приватного ключа данного пользователя. Генерирует ответный пакет для нового пользователя с своим зашифрованным AES ключем, сохраняет мастер ключ в свою хэш-таблицу:
Когда пользователь получает пакет с зашифрованным AES ключем, он находит общий мастер ключ с этим пользователем у себя в локальной хэш-таблице (если не успел получить публичный ключ собеседника, то выполняет запрос к серверу, у которого хранятся все публичные ключи), расшифровывает и сохраняет симметричный ключ для данного пользователя.
Расшифровка непосредственно передаваемого сообщения. Когда все участники чата получают сообщение, то находят у себя локально симметричный ключ для данного пользователя и расшифровывают переданный текст:
И последний тип пакета, который обрабатывает клиент — пул публичных ключей участников чата, который передает сам сервер.
Мелкие системные методы:
enterToChat() (Вход в чат) — стартовый метод рукопожатия. Формирует приветственный пакет, упаковывает в него наш публичный ECDH-ключ и отправляет на сервер, заявляя о своем присутствии в сети.
enterToChat() (Вход в чат) — стартовый метод рукопожатия. Формирует приветственный пакет, упаковывает в него наш публичный ECDH-ключ и отправляет на сервер, заявляя о своем присутствии в сети.
createTopologyTable(peers) (Инициализация таблицы связей) — вызывается новичком при получении списка участников от сервера. Метод итерируется по всем «старичкам», перемножает наш приватный ECDH-ключ с их публичными ключами, вычисляет уникальные общие секреты (shared secrets) для каждой пары и сохраняет их в TopologyTable.
createTopologyTable(peers) (Инициализация таблицы связей) — вызывается новичком при получении списка участников от сервера. Метод итерируется по всем «старичкам», перемножает наш приватный ECDH-ключ с их публичными ключами, вычисляет уникальные общие секреты (shared secrets) для каждой пары и сохраняет их в TopologyTable.
BroadcastAES() (Рассылка ключа чата) — отправляет наш главный сессионный AES-ключ всем участникам. Метод проходит по таблице связей, шифрует наш AESKey уникальным общим секретом (SecretECDH) отдельно для каждого пира и отправляет персональные пакеты TypeSenderKey.
BroadcastAES() (Рассылка ключа чата) — отправляет наш главный сессионный AES-ключ всем участникам. Метод проходит по таблице связей, шифрует наш AESKey уникальным общим секретом (SecretECDH) отдельно для каждого пира и отправляет персональные пакеты TypeSenderKey.
getP2PKeys(user) (Получение крипто-ключей собеседника) — внутренний менеджер ключей. Он достает сохраненные ключи (ECDH-секрет и AES-ключ) для конкретного пользователя. Если пользователя почему-то нет в таблице, метод делает фолбек-запрос к серверу (getTopologyTable) для обновления списка.
getP2PKeys(user) (Получение крипто-ключей собеседника) — внутренний менеджер ключей. Он достает сохраненные ключи (ECDH-секрет и AES-ключ) для конкретного пользователя. Если пользователя почему-то нет в таблице, метод делает фолбек-запрос к серверу (getTopologyTable) для обновления списка.
getTopologyTable() (Запрос списка участников) — сервисный метод, который отправляет серверу сигнал TypeGetPeerList с требованием принудительно прислать актуальную карту сети (используется для синхронизации, если прилетело сообщение от неизвестного адреса).
getTopologyTable() (Запрос списка участников) — сервисный метод, который отправляет серверу сигнал TypeGetPeerList с требованием принудительно прислать актуальную карту сети (используется для синхронизации, если прилетело сообщение от неизвестного адреса).
sendMessage(packet) (Низкоуровневая отправка по TCP) — транспортный узел клиента. Сериализует любой готовый пакет message.Packet в JSON-байты, принудительно добавляет в конец символ переноса строки \n (чтобы bufio.Scanner на стороне сервера четко понимал, где заканчивается пакет) и пуляет данные в сокет.
sendMessage(packet) (Низкоуровневая отправка по TCP) — транспортный узел клиента. Сериализует любой готовый пакет message.Packet в JSON-байты, принудительно добавляет в конец символ переноса строки \n (чтобы bufio.Scanner на стороне сервера четко понимал, где заканчивается пакет) и пуляет данные в сокет.
addECDH(user, ecdhKey) (Сохранение ECDH-секрета) — безопасный хелпер для атомарного обновления таблицы. Находит структуру пира в мапе, аккуратно записывает туда вычисленный асимметричный секрет и сохраняет измененную копию обратно в TopologyTable.
addECDH(user, ecdhKey) (Сохранение ECDH-секрета) — безопасный хелпер для атомарного обновления таблицы. Находит структуру пира в мапе, аккуратно записывает туда вычисленный асимметричный секрет и сохраняет измененную копию обратно в TopologyTable.
addAES(user, aesKey) (Сохранение AES-ключа собеседника) — финальный шаг крипто-настройки. Вызывается, когда нам прилетает расшифрованный AES-ключ от соседа. Находит нужного пользователя в мапе, «впаивает» его симметричный ключ в поле .AES и перезаписывает структуру в мапе, подготавливая клиента к чтению сообщений от этого юзера.
addAES(user, aesKey) (Сохранение AES-ключа собеседника) — финальный шаг крипто-настройки. Вызывается, когда нам прилетает расшифрованный AES-ключ от соседа. Находит нужного пользователя в мапе, «впаивает» его симметричный ключ в поле .AES и перезаписывает структуру в мапе, подготавливая клиента к чтению сообщений от этого юзера.
Дорабатываем сервер
Теперь нам нужно проработать логику сервера, чтобы он корректно хранил публичные ключи пользователей, обрабатывал пакеты и поддерживал рукопожатие.
Структура сервера:
В структуру пользователя добавили его публичный ключ.
В структуру пользователя добавили его публичный ключ.
канал теперь будет передавать пакеты.
канал теперь будет передавать пакеты.
Добавляем рукопожатие и переносим в него регистрацию пользователя:
В рукопожатии ждем первый пакет авторизации от нового кользопателя. Регистрируем его на сервере вместо с публичным ключем. Передаем пакет с типом TypeNewPeer. Передаем его в обработку пакетов. Далее создаем пакет со списком всех участников чата и их публичных ключей для нового пользователя.
Генерируем пакет со списком публичных ключей:
Обработка полученных пакетов:
Если пакет с публичным ключем нового пользователя или текстовое сообщение, то отправляем его на широковещательную рассылку:
Если пакет нацелен на конкретного пользователя (передается список публичных ключей участников чата или передается зашифрованный симметричный ключ), тогда шлем напрямую:
Запрос пользователя на принудительную отправку пулы публичных ключей:
Процесс шифрования готов! Добавим на сторону сервера попытку прочитать пакет с сообщением:
Подключаемся к серверу с двух клиентов и отправляем сообщение:
Успешно читаем на другом клиенте:
Что видит сервер (мешанину из байт):
Определенный успех для показательного примера внедрения End to End шифрования.
Мы успешно реализовали честное End-to-End шифрование для нашего TCP-чата. Да, наш сервер всё еще координирует сеть и пересылает пакеты (выполняет роль сигнального сервера и роутера), но он больше не имеет доступа к самим сообщениям. Как видно из логов, попытка сервера десериализовать сообщение падает с ошибкой — для него это просто массив случайных байт.
Мы построили гибридную схему:
ECDH на кривой X25519 безопасно связал клиентов и помог им выработать общий секрет без риска перехвата.
ECDH на кривой X25519 безопасно связал клиентов и помог им выработать общий секрет без риска перехвата.
AES-256 в режиме GCM взял на себя тяжелую работу по шифрованию больших потоков текста и гарантировал, что никто не сможет изменить байты сообщения в процессе транзита.
AES-256 в режиме GCM взял на себя тяжелую работу по шифрованию больших потоков текста и гарантировал, что никто не сможет изменить байты сообщения в процессе транзита.
Наш чат стал приватным, но пока не стал по-настоящему надежным и безопасным от более изощренных атак. В следующих статьях мы разберем:
Защиту от MITM (Man-in-the-Middle): Сервер не может прочитать сообщения, но что мешает вредоносному серверу подменить публичные ключи участников в момент рукопожатия и читать всё? Будем решать проблему доверия к ключам.
Защиту от MITM (Man-in-the-Middle): Сервер не может прочитать сообщения, но что мешает вредоносному серверу подменить публичные ключи участников в момент рукопожатия и читать всё? Будем решать проблему доверия к ключам.
Аутентификацию пользователей: Добавим полноценную сессию, подписи пакетов и разберемся, как привязать постоянный профиль пользователя к его временным сессионным ключам.
Аутентификацию пользователей: Добавим полноценную сессию, подписи пакетов и разберемся, как привязать постоянный профиль пользователя к его временным сессионным ключам.