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

Yet another P2P Messenger


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


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


Пример 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) {
        handleHttp(readWriter, conn, p)
    } 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.


Для этого создадим MyWriter и реализуем в нем методы для соответствия интерфейсам http.ResponseWriter и 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) ключа — над ними еще нужно поколдовать:


func CreateKeyExchangePair() (publicKey [32]byte, privateKey [32]byte) {
    pub, priv, err := ed25519.GenerateKey(nil)
    /* ... */
    copy(publicKey[:], pub[:])
    copy(privateKey[:], priv[:])
    curve25519.ScalarBaseMult(&publicKey, &privateKey)
   /* ... */
}

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


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


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


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


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


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


//CalcSharedSecret Calculate shared secret
func CalcSharedSecret(publicKey []byte, privateKey []byte) (secret [32]byte) {
    var pubKey [32]byte
    var privKey [32]byte
    copy(pubKey[:], publicKey[:])
    copy(privKey[:], privateKey[:])
    curve25519.ScalarMult(&secret, &privKey, &pubKey)
    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 в рамках конкурса от Павла Дурова.


Для генерации эфемерного ключа в то время в телеграмм использовался самый обычный протокол Диффи — Хеллмана.


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


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.


По-умолчанию это $HOME/go. 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


  • Самое важное что хотелось бы отметить: на go сразу получается реализовать то, что задумал.
    Почти все необходимое есть в стандартной библиотеке.
  • Однако, была и сложность, когда я начал проект в отличном от GOPATH каталоге.
    Для написания кода использовал GoLand. И поначалу смущало автоматическое форматирование кода с автоимпортом библиотек.
  • В IDE много кодогенераторов, что позволяло сосредоточится на разработке, а не на наборе кода.
  • К частой обработке ошибок быстро привыкаешь, но случается рука-лицо, когда понимаешь что для go нормальная ситуация, когда суть ошибки анализируется по ее строковому представлению.
    err != io.EOF
  • Чуть лучше дело обстоят с библиотекой os. Там понять суть проблемы помогают такие конструкции
    if os.IsNotExist(err) { /* ... */ }
  • Из коробки go учит нас правильно документировать код и писать тесты.
    И тут есть свои но. Мы описали интерфейс с методом ToJson().
    Так вот, генератор документации не наследует описание этого метода на методы его реализующие, поэтому чтобы убрать лишние варниги, приходится копировать документацию в каждый реализованный метод (proto/mtypes.go).
  • В последнее время привык к мощи log4j в java, поэтому не хватает хорошего логгера в go.
    Наверное, стоит поискать на просторах гитхаба красивое логгирование с аппендерами и форматерами.
  • Непривычна работа с массивами.
    Например, конкатенация происходит через функцию append, а преобразование массива произвольной длины в массив фиксированной длины через copy.
  • switch-case работает как if-elseif-else — а вот это интересный подход, но опять рука-лицо:
    если хотим привычное поведение switch-case, нужно у каждого кейса проставлять fallthrough.
    А еще можно использовать goto, но давайте не будем, пожалуйста!
  • Нет тернарного оператора и часто это не удобно.

Что дальше?


Вот и реализован простейший 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.


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

Комментарии 25

    +11

    КДПВ огонь

      +4
      if padding != 0 {
          repeat := bytes.Repeat([]byte("\x00"), aes.BlockSize-(padding))
          content = append(content, repeat...)
      }
      ...
      encrypted = bytes.Trim(encrypted, string([]byte("\x00")))

      Лучше так не делать, т.к. если в конце сообщения окажутся нулевые байты, то они будут откушены. Лучше воспользоваться padding'ом из PKCS#7

        0
        Хорошее замечание, учту!
        Однако, шифруется json, и он всегда заканчивается на печатные байты "}" или "]", поэтому в текущей реализации проблем быть не должно.
          +5
          А через пару лет кто то придумает как использовать другой баг добавляющий нули к json-у чтобы устроить MITM.
            0
            А ещё лучше использовать GCM режим шифрования (crypto/cipher.NewGCM) и не париться с padding-ом вообще: и код проще, и только шифрование используется, и распараллеливается. А самое главное: GCM автоматически даст аутентификацию сообщений. Шифрование без аутентификации… мягко говоря, недопустимо.

            habr.com/ru/post/452200 — вот тут есть пример гораздо более безопасного решения, но на Python. Из сложностей: ASN.1 вместо простой сериализации как у вас (простота это тоже хорошо). И просто для справки: если хочется ГОСТ криптографию, то вот для Go есть GoGOST: gogost.cypherpunks.ru
          0
          Неплохая статья, очень даже неплохая.
          По логам — мы используем github.com/uber-go/zap с небольшой оберткой, которая позволяет нам создавать нужные аппендеры на лету. Форматирование zap тоже поддерживает, но если хочется свой формат лог записей, то нужно написать немного кода. Универсализма как в log4j я пока не видел.
            +13
            Изобретать очередной E2E протокол для общения конечно прикольно, но только давайте не будем называть его безопасным. В то время как уже давно есть и Double Ratchet и Noise Protocol это моветон.
            Что касается конкретно вашей реализации:
            1) ID пира должен быть хэшем от его статического публичного ключа, иначе MITM
            2) Эфемерные ключи не подписаны. Тоже MITM
            2) Вместо AES-CBC лучше использовать AES-GCM/AES-GCM-SIV, либо добавить HMAC. У вас нет никакой проверки корректности расшифрованного текста, можно напихать туда что угодно и собеседник ничего не заподозрит

            Это только то, что сразу бросилось в глаза
              +1

              Спасибо за препарацию моей самоделки и за Noise Protocol.
              Я расчитывал как раз на подобный разбор.


              1) Сейчас ID пира есть его публичный ключ. Первоначально не ставил задачу его скрывать, да и протокол был бы не такой лаконичный, а на будущее учту этот момент. Правда, не совсем понял как это уязвимо для MITM?


              2) Эфемерные ключи передаются в подписанном конверте, поэтому можно считать что они подписаны


              func (p Proto) SendName(peer *Peer) {
                   /* ... */
                  sign := ed25519.Sign(p.privKey, handShake)
                  envelope := NewSignedEnvelope("HAND", p.PubKey[:], make([]byte, 32), sign, handShake)
              }

              Другое дело, что подпись проверяется, но реакция на некорректность реализована недостаточно.


              3) Да, проверка корректности отсутствует. Была идея реализовать как в телеграмме AES+IGE. Была мысль, что контент сам себя провалидирует, если будет представлять собой gzip, в который я хотел упаковывать все сообщения для экономии трафика. А там уже проверки гзиповские будут. Но соглашусь, лучше использовать проверку корректности на основе режима шифрования.


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

                +3
                Любая датаграмма (или пакет) могут быть сгенерированы в сеть третьей стороной. Поэтому недостаточно просто проверить корректность формата, каждое сообщение необходимо авторизовать, причем делать это нужно за константное время (иначе возможны тайминг-атаки) см. реализацию MAC (message authentication code) из TLS.
                  0
                  Если что, вот моя реализация NoiseSocket. Это Noise protocol, который умеет разговаривать по сети. Там и примеры есть
                0

                Технически статью осилить не способен, но за картинку плюс :)

                  0
                  На github вы не выкладывали проект?
                  0
                  Впечатляет! КТо-то пишет при обучении языку хэллоуворды, кто-то очережной твиттер, а мсье п2п мессенжер с е2е
                  причем с гуем на реакте, общением через один порт и вот этим всем
                  Полагаю, что возьми вы либп2п, навернка быстрее и лучше сбацаете (:
                    +1
                    Хотелось прочувствовать go своими руками, поэтому libp2p сразу отложил
                      +1
                      С libp2p еще разобраться нужно. И это не быстро, раньше там документации с гулькин нос было, сейчас, может, лучше стало.
                      +2
                      Легкий грамматический троллинг :)
                      p2p мессерджер (кстати, есть ли русский синоним этому слову?).

                      Да. Мы в России его называем мессенджер :)


                      Если серьезно, то встречал разные, но "Система обмена мгновенными сообщениями" как-то сложно звучит, "болталка" — слишком несерьезно. Иногда встречал "чат", но это больше к виджету мессенджера, вставленному на сайт.

                        0
                        Спасибо
                        Опечатку исправил, правда на скрине она же =)
                        Чат — слово нерусское. Может «вещатель»?

                        +1

                        Интересная статья.
                        Думаю, многим будет интересно "потыкать" проект. Но для тех, кто go не собирал поначалу может быть сложно. Можно собрать проект под разные системы (благо в go кросс-компиляция несложная) и положить статическим бинарен на GitHub. Или в крайнем случае — Docker образ. Так они смогут его скачать и запустить, а вы получить обширную обратную связь.

                          0
                          А можно в двух словах для не очень сообразительных, почему наименование половины функций начинается с маленькой буквы, а половина с большой?
                            0
                            Это еще одна особенность go.
                            Публичные (экспортируемые) методы пишутся с большой буквы, а приватные (неэкспортируемые) с маленькой. Такая себе инкапсуляция =)
                            golang.org/ref/spec#Exported_identifiers
                              +1
                              Т.е. капс вместо public?
                                0

                                Капс как я понимаю это когда всё с большой буквы. А в GO только регистр первого символа имеет значение.

                            –1

                            Еще такое обучение нашел — огонь!


                            https://www.youtube.com/playlist?list=PLd-kTafWJCJOUBLIkAXI8h9Bxu4tOKlwn

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

                            Самое читаемое