Pull to refresh

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

Reading time9 min
Views44K

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

Tags:
Hubs:
+53
Comments25

Articles