March 21, 2024

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

Всем привет друзья! Это продолжение прошлой статьи про данный алгоритм https://habr.com/ru/articles/726324/ . Где я рассказывал про возможность общения между двумя пользователями без прямого обмена ключом шифрования. В своем телеграм канале я уже описывал идею созданию прозрачного Open-Source месенджера на основе этого алгоритма и хочу представить вам его самую простую реализацию.

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

  1. Покупатель подключается к кабинету, используя свою сид фразу. (Полученный приватный ключ из этой сид фразы, в то время, у нас хранился в LocalStorage - несекьюрно согласен, но сейчас не об этом)
  2. Пользователь на странице магазина выбирал желаемый мерч.
  3. Заполнял форму: Артикул товара, ФИО и адрес доставки.
  4. Отправлял транзакцию с приложенной информацией и платежом за мерч. (Информация шифровалась по DH алгоритму на основе его приватного ключа и публичного ключа нашего магазина)
  5. На стороне магазина отслеживались все полученные транзакции и по публичному ключу отправителя + приватный ключ нашего магазина мы могли расшифровать эти сообщения.

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

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

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

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

Сервер

Первое, что нам нужно, это сервер, который будет служить доставщиком сообщений. Первично я сделал простую реализацию по Rest и для хранения сообщений использую PostgreSQL. Для этого я взял свой шаблон из этого репозитория https://github.com/vseriousv/go-api-template.

Для миграций использую пакет 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 для начинающих, я не стану рассказывать про то как это все писалось, тем более описание этого шаблона есть в Readme в репозитории. Поэтому я расскажу только про основные моменты сервера.

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

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"`
}

Описание сервера на этом закончу, потому что он очень простой и со всем кодом вы сможете ознакомиться по данной ссылке: https://github.com/vseriousv/go_diffie_hellman_chat_server

Клиент

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

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

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

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

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

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

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

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

И следующее окно будет выглядеть так:

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

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

Отправим новое сообщение другому пользователю.

И вот так оно сохраняется в базу данных:

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

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
}

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

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

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:

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://github.com/vseriousv/go_diffie_hellman_chat_server

Клиент: https://github.com/vseriousv/go_diffie_hellman_chat

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