Хабрахабр

[Из песочницы] Изучая go: пишем p2p мессенджер со сквозным шифрованием

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

Обязательно для закрепления нужно создать что-то интересное, чтобы наработки можно было бы использовать в других своих задачах.

Пример UI чата на ReactJs

Статья ориентирована на новичков интересующихся языком go и пиринговыми сетями.
И для профессионалов, умеющих предлагать разумные идеи или конструктивно критиковать.

Программирую достаточно давно с разной степенью погруженности на java, php, js, python.
И каждый язык программирования хорош в своей сфере.

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

Но микросервисы должны ещё уметь общаться друг с другом, поэтому инструмент для создания микросервисов должен позволять легко и без боли организовывать сетевое взаимодействие.
Чтобы проверить это напишем приложение организовывающее децентрализованную сеть равноправных участников (Peer-To-Peer), самое простое — p2p мессерджер (кстати, есть ли русский синоним этому слову?).

В коде активно изобретаю велосипеды и наступаю на грабли, чтобы прочувствовать golang, получить конструктивную критику и рациональные предложения.

Что делаем

Пир (peer) — уникальный экземпляр мессенджера.

Наш мессенджер должен уметь:

  • Находить соседние пиры
  • Устанавливать соединение с другими пирами
  • Шифровать обмен данными с пирами
  • Принимать сообщения от пользователя
  • Показывать сообщения пользователю

Чтобы задачку сделать чуть интереснее, давайте сделаем так, чтобы все это проходило через один сетевой порт.

Условная схема работы мессенджера

Если дернуть этот порт по HTTP, то получим реактовское приложение, которое дернет этот же порт, установив web socket соединение.

Если дергать порт по HTTP не с локальной машины, то показываем баннер.

Если к этому порту подключается другой пир, то происходит установка постоянного соединения со сквозным (end-to-end) шифрованием.

Определяем тип входящего соединения

Для начала откроем порт для прослушивания и будем ждать новых соединений.

net.ListenTCP("tcp", tcpAddr)

На новое соединение читаем первые 4 байта.

Берем список глаголов HTTP и сравниваем с ним наши 4 байта.

Теперь определяем с локальной ли машины происходит подключение, и если нет, то отвечаем баннером и "вешаем трубку".

buf, err := readWriter.Peek(4) /* обработка ошибки */ if ItIsHttp(buf) else { peer := proto.NewPeer(conn) p.HandleProto(readWriter, peer) } /* ... */ if !strings.EqualFold(s, "127") && !strings.EqualFold(s, "[::") { response.Body = ioutil.NopCloser(strings.NewReader("Peer To Peer Messenger. see https://github.com/easmith/p2p-messenger")) }

Если же подключение локальное, то отвечаем файлом, соответствующим запросу.

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

// свой способ func processRequest(request *http.Request, response *http.Response) {/* много строчек кода */} // либо из страндартной библиотеки fileServer := http.FileServer(http.Dir("./front/build/")) fileServer.ServeHTTP(NewMyWriter(conn), request)

Если же запрашивается путь /ws, то пробуем установить websocket соединение.

Раз уж я собрал велосипед в обработке запросов файлов, то обработку ws соединения сделаю с помощью библиотеки gorilla/websocket.

ResponseWriter и http. Для этого создадим MyWriter и реализуем в нем методы для соответствия интерфейсам http. Hijacker.

// w - MyWriter func handleWs(w http.ResponseWriter, r *http.Request, p *proto.Proto) { c, err := upgrader.Upgrade(w, r, w.Header()) /* теперь работаем с соединением почти как с обычным сокетом */ }

Обнаружение пиров

Для поиска пиров в локальной сети воспользуемся мультикастом UDP.

Будем отправлять на Multicast IP адрес пакеты с информацией о нас самих.

func startMeow(address string, p *proto.Proto) { conn, err := net.DialUDP("udp", nil, addr) /* ... */ for { _, err := conn.Write([]byte(fmt.Sprintf("meow:%v:%v", hex.EncodeToString(p.PubKey), p.Port))) /* ... */ time.Sleep(1 * time.Second) } }

И отдельно прослушивать от Multicast IP все UDP пакеты.

func listenMeow(address string, p *proto.Proto, handler func(p *proto.Proto, peerAddress string)) { /* ... */ conn, err := net.ListenMulticastUDP("udp", nil, addr) /* ... */ _, src, err := conn.ReadFromUDP(buffer) /* ... */ // connectToPeer handler(p, peerAddress) }

Таким образом мы заявляем о себе и узнаем о появлении других пиров.

Можно было бы организовать это на уровне IP и даже в официальной документации пакета IPv4 в качестве примера кода приводится как раз multicast пакета данных.

Протокол взаимодействия пиров

Будем все общение между пирами упаковывать в конверт (Envelope).

На любом конверте всегда есть отправитель и получатель, к этому всему мы добавим команду (которую он с собой несет), идентификатор (пока это случайное число, но можно сделать как хэш содержимого), длина содержимого и само содержимое конверта — сообщение или параметры команды.

Байты конверта

Команда, (или же тип содержимого) удачно расположим в самом начале конверта и определим список команд из 4 байт, не пересекающихся с именами глаголов HTTP.

Весь конверт при передаче сериализуется в массив байт.

Рукопожатие

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

В ответ пир получает аналогичный набор данных, регистрирует найденный пир в своем списке и вычисляет (CalcSharedSecret) общий сессионный ключ.

func handShake(p *proto.Proto, conn net.Conn) *proto.Peer { /* ... */ peer := proto.NewPeer(conn) /* Отправляем свое имя и ключ*/ p.SendName(peer) /* Ждем имя и ключ */ envelope, err := proto.ReadEnvelope(bufio.NewReader(conn)) /* ... */ }

Обмен пирами

После рукопожатия, пиры обмениваются своими списками пиров =)

Для этого отправляется конверт с командой LIST, а в его содержимое кладется JSON список пиров.
В ответ получаем аналогичный конверт.

Находим в списках новых и с каждым из них проделываем попытку соединения, рукопожатия, обмена пирами и так далее…

Обмен пользовательскими сообщениями

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

О шифровании

В стандартных (гугловых) библиотеках golang из пакета crypto реализовано множество всяких разных алгоритмов (ГОСТовских нет).

Будем использовать библиотеку ed25519 для подписи сообщений. Наиболее удобной для подписей считаю кривую Ed25519.

В самом начале я подумывал использовать пару ключей полученных из ed25519 не только для подписи, но и для генерации сессионного ключа.

Однако, ключи для подписи применимы для вычисления общего (shared) ключа — над ними еще нужно поколдовать.

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

Для любителей математики вот ссылки на wiki:
ПротоколДиффи—_Хеллмана_на_эллиптических_кривых
Цифровая подпись EdDSA

Генерация общего ключа вполне стандартная: сначала для нового соединения генерим эфемерные ключи, отправляем в сокет конверт с публичным ключом.

Противоположная сторона делает то же самое но в другом порядке: получает конверт с публичным ключом, генерит свою пару и отправляет публичный ключ в сокет.

Теперь у каждого участника есть чужой публичный и свой приватный эфемерные ключи.

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

//CreateKeyExchangePair create pair for ECDHE func CreateKeyExchangePair() (publicKey [32]byte, privateKey [32]byte) { pub, priv, err := ed25519.GenerateKey(nil) /* обработка ошибки */ copy(publicKey[:], pub[:]) copy(privateKey[:], priv[:]) curve25519.ScalarBaseMult(&publicKey, &privateKey) return }

Шифровать сообщения будем поштучно давно зарекомендовавшим себя алгоритмом AES в режиме сцепления блоков (CBC).

Вся эта реализации легко находятся в документации golang.

Единственная доработка — авто заполнение сообщения нулевыми байтами для кратности его длины к длине блока шифрования (16 байт).

//Encrypt the message func Encrypt(content []byte, key []byte) []byte { padding := len(content) % aes.BlockSize if padding != 0 { repeat := bytes.Repeat([]byte("\x00"), aes.BlockSize-(padding)) content = append(content, repeat...) } /* ... */ } //Decrypt encrypted message func Decrypt(encrypted []byte, key []byte) []byte { /* ... */ encrypted = bytes.Trim(encrypted, string([]byte("\x00"))) return encrypted }

В далеком 2013 году реализовывал AES (с похожим на CBC режимом) для шифрования сообщений в Telegram в рамках конкурса от Павла Дурова.

Для генерации эфемерного ключа в то время в телеграмм использовался самый обычный протокол Диффи — Хеллмана (https://ru.wikipedia.org/wiki/Протокол_Диффи_—_Хеллмана).

А чтобы исключить нагрузку от фейковых подключений перед каждым обменом ключами клиенты решали задачу факторизации.

GUI

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

Тут без заморочек — ReactJS + websocket.

Веб-сокет сообщения по сути своеобразные конвертики, только они не содержат в себе шифротекстов.

Все они "наследники" типа WsCmd и при передаче сериализуются в JSON.

//Serializable interface to detect that can to serialised to json type Serializable interface { ToJson() []byte } func toJson(v interface{}) []byte { json, err := json.Marshal(v) /* обработка err */ return json } /* ... */ //WsCmd WebSocket command type WsCmd struct { Cmd string `json:"cmd"` } //WsMessage WebSocket command: new Message type WsMessage struct { WsCmd From string `json:"from"` To string `json:"to"` Content string `json:"content"` } //ToJson convert to JSON bytes func (v WsMessage) ToJson() []byte { return toJson(v) } /* ... */

Итак, приходит HTTP запрос на корень ("/"), теперь чтобы отобразить фронт заглядываем в каталог “front/build” и отдаем index.html

Что ж интерфейс сверстан, теперь выбор для пользователей: запускать его в браузере или в отдельном окошке — WebView.

Для последнего варианта использовал zserge/webview

e := webview.Open("Peer To Peer Messenger", fmt.Sprintf("http://localhost:%v", initParams.Port), 800, 600, false)

Для сборки приложения с ним нужно установить еще либу в систему

sudo apt install libwebkit2gtk-4.0-dev

В ходе раздумий над GUI нашел множество библиотек для GTK, QT, и очень по гиковски смотрелся бы консольный интерфейс — https://github.com/jroimartin/gocui — по-моему очень даже интересная идея.

Запуск мессенджера

Установка golang

Конечно, сначала нужно установить go.
Для этого настоятельно рекомендую воспользоваться инструкцией golang.org/doc/install.

Упростил инструкцию до bash скрипта

Загрузка приложения в GOPATH

Так уж устроен go, что все библиотеки и даже ваши проекты должны лежать в так называемом GOPATH.

Go позволяет стянуть исходники из публичного репозитория простой командой: По-умолчанию это $HOME/go.

go get github.com/easmith/p2p-messenger

Теперь в вашем каталоге $HOME/go/src/github.com/easmith/p2p-messenger появится исходник из ветки master

Установка npm и сборка фронта

Как писал выше, наш GUI — веб-приложение с фронтом на ReactJs, поэтому фронт ещё нужно собрать.

Nodejs + npm — тут как обычно.

На всякий случай вот инструкция для убунту

Теперь стандартно запускаем сборку фронта

cd front
npm update
npm run build

Фронт готов!

Запуск

Перейдем обратно в корень и запустим пир нашего мессенджера.

При запуске можем указать имя своего пира, порт, файл с адресами других пиров и флаг указывающий запускать ли WebView.

По-умолчанию используется $USER@$HOSTNAME в качестве имени пира и порт 35035.

Итак, запускаем и чатимся с друзьями по локальной сети.

go run app.go -name Snowden

Отзыв о программировании на golang

Что дальше?

Вот и реализован простейший Peer-To-Peer мессенджер.

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

А можно не изобретать свой протокол, а использовать гугловый Protocol Buffers,
подключить блокчейн и защититься от спама с помощью смарт-контрактов Ethereum.

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

Еще обязательно запустить seed пиры, реализовать обход NAT и передачу сообщений от пира к пиру.

В итоге получится неплохая замена телеграмма/вотсапа, останется только всех друзей туда пересадить =)

Полезности

Немного ссылок

В ходе работы над мессенджером нашел интересные для начинающего go разработчика страницы.
Делюсь ими с вами:

Эту же документацию можно запустить локально командой golang.org/doc/ — документация по языку, все просто, понятно и с примерами.

godoc -HTTP=:6060

gobyexample.com — сборник простых примеров

golang-book.ru — хорошая книга на русском

github.com/dariubs/GoBooks — сборник книг о Go.

Категоризация более менее, а вот описание многих из них очень скудная, что не помогает поиску по Ctrl+F awesome-go.com — список интересных библиотек, фреймворков и приложений на go.

Показать больше

Похожие публикации

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»