golang

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

  • пятница, 29 марта 2024 г. в 00:00:17
https://habr.com/ru/articles/802815/

Введение

Всем привет! Это продолжение прошлой статьи про данный алгоритм. Где я рассказывал про возможность общения между двумя пользователями без прямого обмена ключом шифрования. В своем телеграм‑канале я уже описывал идею создания прозрачного 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
}

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

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

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

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

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

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

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