Введение
Всем привет! Это продолжение прошлой статьи про данный алгоритм. Где я рассказывал про возможность общения между двумя пользователями без прямого обмена ключом шифрования. В своем телеграм‑канале я уже описывал идею создания прозрачного Open‑Source мессенджера на основе этого алгоритма и хочу представить вам его самую простую реализацию с примерами кода.
Предупреждаю, что кода будет много, но также будет много комментариев и объяснений.
Все исходники я выложил в своем github, ссылки будут в конце этой статьи. Но для начала небольшая предыстория. Около 5 лет назад я работал на одном блокчейн проекте, мне необходимо было реализовать мерч‑магазин, где можно было купить вещи за внутреннюю валюту сети. Весь процесс выглядел так:
Покупатель подключается к кабинету, используя свою сид фразу. (Полученный приватный ключ из этой сид фразы, шифровался паролем при входе и хранился в LocalStorage — небезопасно согласен, но сейчас не об этом).
Пользователь на странице магазина выбирал желаемый мерч.
Заполнял форму: Артикул(товара из карточки товара), ФИО и адрес доставки.
Отправлял транзакцию с приложенной информацией и платежом за мерч. (Информация шифровалась по DH алгоритму на основе его приватного ключа и публичного ключа нашего магазина)
На стороне магазина отслеживались все полученные транзакции и по публичному ключу отправителя + приватный ключ нашего магазина мы могли расшифровать эти сообщения.
Это был интересный опыт в моей карьере, на основе этой модели у нас в компании было реализовано еще несколько проектов по передаче секьюрных данных через блокчейн.
Однако когда мы с коллегами или друзьями хотели отправить какие‑то секреты, например переменные окружения или приватные ключи, мы использовали несколько мессенджеров, разделяя это сообщения например на три части и высылая каждую часть в разных приложениях, но это во первых неудобно, а во вторых, это также небезопасно.
Тогда мне пришла мысль сделать свою реализацию корпоративного обменника секретами, который в будущем можно будет использовать как 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 }
где также все начинается с генерации транспортного ключа, когда мы используем свой приватный ключ и публичный ключ друга, как и при шифровании:

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

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