Алгоритм Diffie-Hellman: Пишем приватный мессенджер на Go
Введение
Всем привет! Это продолжение прошлой статьи про данный алгоритм: https://habr.com/ru/articles/726324/ . Где я рассказывал про возможность общения между двумя пользователями без прямого обмена ключом шифрования. В своем телеграм канале я уже описывал идею создания прозрачного 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
}
где также все начинается с генерации транспортного ключа, когда мы используем свой приватный ключ и публичный ключ друга, как и при шифровании:
Окно сообщения
Если мы зайдем в аккаунт друга (Боба), то увидим почти тоже самое, за исключением того что будет указано, что именно Боб был получателем, а не отправителем. И второе отличие в том, что для генерации транспортного ключа Боб использует уже свой приватный ключ и публичный ключ Алисы.
Отображение сообщения в двух аккаунтах
Конечно же это не продакшен реализация и даже не МВП, а только черновик, который просто показывает как можно использовать данный алгоритм в работе или даже в жизни. Проект носит исключительно познавательный характер. А имеет ли смысл развивать его как мессенджер, наверное нет, но на базе этой реализации можно сделать тот же самый мерч-магазин, как из примера в начале статьи или другие варианты на ваше усмотрение.
Весь код можно посмотреть в этих репозиториях:
Всем спасибо за внимание.