Алгоритм Diffie-Hellman: Пишем приватный мессенджер на Go

от автора

Введение

Всем привет! Это продолжение прошлой статьи про данный алгоритм. Где я рассказывал про возможность общения между двумя пользователями без прямого обмена ключом шифрования. В своем телеграм‑канале я уже описывал идею создания прозрачного Open‑Source мессенджера на основе этого алгоритма и хочу представить вам его самую простую реализацию с примерами кода.

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

Все исходники я выложил в своем github, ссылки будут в конце этой статьи. Но для начала небольшая предыстория. Около 5 лет назад я работал на одном блокчейн проекте, мне необходимо было реализовать мерч‑магазин, где можно было купить вещи за внутреннюю валюту сети. Весь процесс выглядел так:

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

  2. Пользователь на странице магазина выбирал желаемый мерч.

  3. Заполнял форму: Артикул(товара из карточки товара), ФИО и адрес доставки.

  4. Отправлял транзакцию с приложенной информацией и платежом за мерч. (Информация шифровалась по DH алгоритму на основе его приватного ключа и публичного ключа нашего магазина)

  5. На стороне магазина отслеживались все полученные транзакции и по публичному ключу отправителя + приватный ключ нашего магазина мы могли расшифровать эти сообщения.

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

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

Тогда мне пришла мысль сделать свою реализацию корпоративного обменника секретами, который в будущем можно будет использовать как ETH‑wallet и как приватный мессенджер, а также заодно попробовать себя в разработке ПО. В итоге эту идею я откладывал до прошлого года и после прошлой статьи захотел попробовать ее реализовать.

В качестве языка программирования я решил продолжить использовать GoLang, так как у меня на нем уже есть несколько рабочих библиотек по данному алгоритму, включая пример из прошлой статьи. А для разработки интерфейса на Go, я решил использовать Fyne. Никогда раньше я не программировал ПО и тем более на Go, поэтому этот опыт был очень интересным.

Сервер

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

Для миграций я использую пакет migrate. И у нас будет всего одна таблица:

create table if not exists messages (     id              serial primary key,     public_key_from varchar not null,     public_key_to   varchar not null,     message         bytea    not null,     created_at      timestamp with time zone default now(),     updated_at      timestamp with time zone default now() )

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

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

Сервер у нас имеет всего две ручки:

router.Post("/v1/messages", messageHandler.CreateMessage) // Отпроавить сообщение router.Get("/v1/messages/{publicKey}", messageHandler.GetMessagesByPublicKey // Получить список все хсообщений по публичному ключу

Первая чтобы записать сообщение в базу данных.

// Структура DTO, которая приходит с клиента type CreateMessageDTO struct { From    string `json:"from"` To      string `json:"to"` Message []byte `json:"message"` }

Сам сервер ничего не шифрует и не расшифровывает, он только записывает в базу то что приходит с клиента:

// ./internal/api/services/message_service.go  // Метод записывает входящее сообщение в базу func (s *MessageService) CreateMessage(dto dto.CreateMessageDTO) (*int64, error) { // Преобразует DTO в Entity entity := dto.GenerateMessageEntity() repo := repositories.NewMessageRepository(s.db)  // Записывает сообщение в базу данных err := repo.Create(context.Background(), entity) if err != nil { return nil, err }  // Возвращает ID записанного сообщения return &entity.Id, nil }

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

// ./internal/api/services/message_service.go  // Получает все сообщения из БД по публичному ключу пользователя func (s *MessageService) GetMessagesByPublicKey(publicKey string) ([]dto.MessageDTO, error) { var messagesRes []dto.MessageDTO  messageRepo := repositories.NewMessageRepository(s.db)  // Получение сообщений по PublicKey messages, err := messageRepo.GetMessagesByPublicKey(context.Background(), publicKey) if err != nil { return nil, err }  // Преобразование всех сущностей БД в DTO for _, message := range messages { var m dto.MessageDTO m.Id = message.Id m.From = message.PublicKeyFrom m.To = message.PublicKeyTo m.Message = message.Message m.CreatedAt = message.CreatedAt m.UpdatedAt = message.UpdatedAt messagesRes = append(messagesRes, m) }  return messagesRes, nil }
// Структура DTO, которая отдается на клиент type MessageDTO struct { Id        int64     `json:"id"` From      string    `json:"from"` To        string    `json:"to"` Message   []byte    `json:"message"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` }

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

Клиент

Вот тут начинается веселье, потому что я впервые делаю интерфейс на GoLang. И как я сказал, для этого я использую библиотеку Fyne, которая доступна по этой ссылке.

Тут много с чего можно было бы начать, хотя бы с того, что все начинается с этих трех строк:

// ./main.go // ...  application := app.New() window := application.NewWindow("DiffHell") window.Resize(fyne.NewSize(400, 500))  /// ...

Где создается приложение с новым окном с названием нашего приложения и размерами. Далее уже пишется логика наполнения этого окна.

Как работает клиент?

Когда пользователь впервые зашел в приложение, то у него будет возможность создать новую пару ключей (приватный + публичный), либо ему будет представлена возможность указать уже имеющийся приватный ключ. Это все, своего рода, авторизация в месcенджере.

Эту логику можно описать таким условием:

// ./main.go // ...      account, err := storage.LoadAccount("./account.json")     // Если файла account.json нет, то выдаем информацию   if err != nil {   dialog.ShowInformation(   "Create account",   "If you already have a private key for Ethereum network,\nyou can create an account with this key,\nthen all your messages will be displayed in your account",   window,   )   }      // Отображает экран аккаунта,     // в котором и будет логика авторизации ui.ShowAccountScreen(c, window, account, services.CreateAccount)      // Запускаем наше окно window.ShowAndRun()  // ...

Функция ShowAccountScreen для отображения аккаунта выглядит так:

// ./internal/ui/account_screen.go  func ShowAccountScreen( c *config.Config, window fyne.Window, account *models.Account, createFunc func(name string, privateKey *string) (*models.Account, error), ) { if account == nil {          // если аккаунт пустой, то отображаем окно авторизации NewAccountScreen(c, window, createFunc) } else {          // если аккаунт есть, то отображаем список сообщений ShowMessageListScreen(c, window, account) } }

Теперь напишем код для отображения окна авторизации, если у пользователя еще нет account.json:

// ./internal/ui/account_screen.go  // NewAccountScreen создает экран для создания нового аккаунта. func NewAccountScreen(c *config.Config, window fyne.Window, createFunc func(name string, privateKey *string) (*models.Account, error)) {     // Создание виджета для поля ввода имени аккаунта.     nameEntry := widget.NewEntry()          // Создание виджета для поля ввода приватного ключа.     privateKeyEntry := widget.NewEntry()     privateKeyEntry.SetPlaceHolder("Optional")      // Создание кнопки для создания аккаунта.     createButton := widget.NewButton("Create Account", func() {         // Подготовка переменной для хранения указателя на приватный ключ, если он будет предоставлен.         var privateKeyPtr *string         if privateKeyEntry.Text != "" {             privateKeyPtr = &privateKeyEntry.Text         }          // Создаем новый аккаунт через функцию createFunc.         account, err := createFunc(nameEntry.Text, privateKeyPtr)         if err != nil {             // В случае ошибки отображение диалогового окна с ошибкой.             dialog.ShowError(err, window)         } else {             // При успешном создании аккаунта переход к экрану со списком сообщений.             ShowMessageListScreen(c, window, account)         }     })      // Компоновка элементов интерфейса.     form := container.NewVBox(         widget.NewLabel("Enter account name:"),         nameEntry,         widget.NewLabel("Enter private key (if you have one):"),         privateKeyEntry,         createButton,     )      // Отрисовываем этот компонент в нашем окне.     window.SetContent(form) } 

И вот как будет выглядеть это окно приветствия, если у пользователя нет файла account.json:

Теперь когда пользователь авторизовался, у него сохраняется конфиг account.json рядом с запущенным приложением, в котором будет записана основная информация:

{   "name": "Alisa",   "private_key": "0x4283104b22a688f347b946462cd62711ef68151deab79845f77fb365f15c0be4",   "public_key": "0x045a03f75542791515050eeab54dfc48698284f93fc345361bb47e02d1d0620f7cdf780417586dcbf6162ba3b8299bec3a945c78aa278eff9409443d22ada6e67f",   "address": "0x304a5cfebBa29255d7730C5B59C28769763d957e" }

После авторизации пользователь попадает на страницу приветствия с отображением списка всех его сообщений. Код этого окна выглядит так:

// ShowMessageListScreen отображает экран со списком сообщений для конкретного аккаунта. func ShowMessageListScreen(c *config.Config, window fyne.Window, account *models.Account) {     // Формирование приветственного сообщения с именем пользователя.     welcomeMessage := "Welcome, " + account.Name + "!"     welcomeLabel := widget.NewLabel(welcomeMessage)      // Создание кнопки для копирования публичного ключа пользователя в буфер обмена.     copyPublicKeyButton := widget.NewButton("Copy PublicKey", func() {         window.Clipboard().SetContent(account.PublicKey)     })      // Создание кнопки для перехода к экрану создания нового сообщения.     newMessageButton := widget.NewButton("New message", func() {         ShowCreateMessageScreen(c, window, account)     })      // Создание контейнера для вертикального расположения элементов интерфейса.     box := container.NewVBox()     box.Add(welcomeLabel)     box.Add(container.NewPadded(container.New(layout.NewGridLayout(2), copyPublicKeyButton, newMessageButton)))      // Создание контейнера для сообщений.     messageBox := container.NewVBox()      // Функция для обновления списка сообщений.     refreshMessages := func() {         // Получение сообщений по публичному ID пользователя.         // Эта функция делает запрос к нашему серверу         messages := services.GetMessagesByPublicId(c, account.PublicKey)          // Перебор и отображение всех полученных сообщений.         for _, m := range messages {             var chatName, companion string              // Форматирование данных о сообщении.             messageData := m.CreatedAt.Format("2006-01-02")             messageTime := m.CreatedAt.Format("15:04")              // Вот тут определяем кто был отправителем, наш пользователь или друг             if m.From == account.PublicKey {                 companion = m.To                 address, err := services.GetAddressFromPublicKey(m.To)                 if err != nil {                     dialog.ShowError(err, window)                     return                 }                 chatName = fmt.Sprintf("%s %s \t Me => %s", messageData, messageTime, utils.AddressShort(address))             } else {                 companion = m.From                 address, err := services.GetAddressFromPublicKey(m.From)                 if err != nil {                     dialog.ShowError(err, window)                     return                 }                 chatName = fmt.Sprintf("%s %s \t %s => Me", messageData, messageTime, utils.AddressShort(address))             }              currentMessage := m              // Добавление кнопки для каждого сообщения в контейнер.             // Чтобы при нажатии мы могли перейти на экран с этим сообщением.             messageBox.Add(widget.NewButton(chatName, func() {                 ShowMessage(c, window, account, companion, currentMessage)             }))         }     }      // Создание таймера для периодического обновления списка сообщений.     // (сюда вебсокеты, но ограничемся простой реализацией)     ticker := time.NewTicker(10 * time.Second)      // Запуск горутины для автоматического обновления сообщений.     go func() {         for range ticker.C {             messageBox.RemoveAll()             refreshMessages()             window.Content().Refresh()         }     }()      // При первом заходе, когда таймер еще не отработал,      // вызываем подгрузку списка сообщений пользователя.     refreshMessages()      // Компоновка элементов интерфейса.     bodyBox := container.NewVBox(         box,         container.NewPadded(messageBox),     )      // Отрисовываем этот компонент в нашем окне.     window.SetContent(bodyBox) } 

А вот отображение этого окна:

Окно приветствия

Окно приветствия

Это же окно будет показываться сразу, если рядом с приложением уже есть файл account.json

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

Давайте отправим сообщение другу, который прислал нам свой публичный ключ:

Окно отправки сообщения

Окно отправки сообщения

На этом экране вставим публичный ключ друга. А в сообщение напишем любую фразу. Нажмем Send.

После этого нас вернет на окно со списком сообщений, где увидем наше отправленное сообщение.

А вот так это сообщение будет записано в БД:

[   {     "id": 1,     "public_key_from": "0x04ed5cdf0bc1a170101f1ea6cf0bc05e920bb1f5f236e74d6ee9d9306d7bf5a76e1ae473bf9d88eea44ecc6d3ac1f134b6aa4c8ceaf8e6e7d70ae3b7807d1742dc",     "public_key_to": "0x0492bf70f6d77c93a9da3e52252e7ce35f4cf7707e33e736fb96617d35253d2dc2e0fdc42c899dfb94f3239fac2eefc9d8a230569f3c2b426a6d282fc019cfeaf5",     "message": "0x558521A4D51B92598A92F145E68D4D72E5EFCB9C4B92513FE8573C335E0A925C60821C5AE2FC7CA61CE4BA2A96433689",     "created_at": "2024-03-25 14:45:19.101212 +00:00",     "updated_at": "2024-03-25 14:45:19.101212 +00:00"   } ]

За шифрование и отправку отвечает следующий код:

// ./internal/services/messages.go  func SendMessage(c *config.Config, msg models.CreateMessageDTO, account *models.Account) (bool, error) {   // Формируется URL для отправки сообщения, используя базовый URL из конфигурации url := fmt.Sprintf("%s/v1/messages", c.ApiUrl)    // Получение транспортного ключа для шифрования сообщения   // Этот ключ получается на основе публичного ключа получателя сообщения и приватного ключа отправителя transportKey, err := transport_key.GetTransportKey(msg.To, account.PrivateKey) if err != nil { return false, err }    // Шифрование самого сообщения с использованием транспортного ключа encryptionMessage, err := encryption.Encrypt(msg.Message, []byte(transportKey)) if err != nil { return false, err }    // Присваиваем зашифрованное сообщение в DTO msg.Message = encryptionMessage    // Преобразование данных сообщения в формат JSON для последующей отправки jsonData, err := json.Marshal(msg) if err != nil { return false, err }    // Отправка сообщения на сервер response, err := http.Post(url, "application/json", bytes.NewBuffer(jsonData)) if err != nil { return false, err }  defer response.Body.Close()  body, err := io.ReadAll(response.Body) if err != nil { return false, err }  log.Println("Response Status:", response.Status) log.Println("Response Body:", string(body))  return true, nil }

Все это я описывал в прошлой статье. Здесь сначала мы генерируем транспортный ключ на основе публичного ключа Боба и своего(Алисы) приватного ключа, а потом шифруем наше сообщение этим транспортным ключом и отправляем на сервер.

Чтобы получить все наши сообщения мы используем наш второй метод:

// ./internal/services/messages.go  func GetMessagesByPublicId(c *config.Config, pubKey string) []models.MessageDTO {   // Формирование URL для запроса списка сообщений, используя базовый URL из конфигурации и публичный ключ url := fmt.Sprintf("%s/v1/messages/%s", c.ApiUrl, pubKey)    // Выполнение GET-запроса к сформированному URL response, err := http.Get(url) if err != nil { log.Printf(err.Error()) return nil }    // Обеспечение закрытия тела ответа после обработки данных для предотвращения утечек ресурсов defer response.Body.Close()    // Инициализация переменной для хранения извлеченных сообщений var messages []models.MessageDTO    // Декодирование JSON-ответа в переменную messages err = json.NewDecoder(response.Body).Decode(&messages) if err != nil { log.Printf("Error happened in sending request. Err: %s", err.Error()) return nil }    // Возвращение списка сообщений в случае успешной операции. return messages }

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

И если мы перейдем внутрь этого сообщения, то вызовется функция DecryptMessage:

// ./internal/services/messages.go func DecryptMessage(account *models.Account, companion string, msg models.MessageDTO) (*string, error) { transportKey, err := transport_key.GetTransportKey(companion, account.PrivateKey) if err != nil { return nil, err }  messageResult, err := encryption.Decrypt(msg.Message, []byte(transportKey)) if err != nil { return nil, err }  return &messageResult, nil }

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

Окно сообщения

Окно сообщения

Если мы зайдем в аккаунт друга(Боба), то увидим почти тоже самое, за исключением того что будет указано, что именно Боб был получателем, а не отправителем. И второе отличие в том, что для генерации транспортного ключа Боб использует уже свой приватный ключ и публичный ключ Алисы.

Отображение сообщения в двух аккаунтах

Отображение сообщения в двух аккаунтах

Конечно же это не продакшен реализация и даже не МВП, а только черновик, который просто показывает как можно использовать данный алгоритм в работе или даже в жизни. Проект носит исключительно познавательный характер. А имеет ли смысл развивать его как мессенджер, наверное нет, но на базе этой реализации можно сделать тот же самый мерч-магазин, как из примера в начале статьи или другие варианты на ваше усмотрение.

Весь код можно посмотреть в этих репозиториях:

Всем спасибо за внимание.


ссылка на оригинал статьи https://habr.com/ru/articles/802815/


Комментарии

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

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