Пишем простой консольный мессенджер с end-to-end шифрованием поверх «Hidden Lake» сервисов

Введение

В свободное от работы время я пишу анонимную сеть Hidden Lake, добавляю в неё новые возможности и параллельно исправляю ранее мной же написанные баги. Вся философия сети Hidden Lake держится на микросервисной архитектуре, на концепции независимых (насколько это только возможно) друг от друга сервисов. В одной из моих статей уже приводился пример того, как мы можем создавать собственный сервис в анонимной сети HL, используя свои технологии и при этом не обращаясь к языку Go (родному языку приложений Hidden Lake).

Но в тот момент присутствовала ограниченность платформы Hidden Lake по созданию неанонимных, но безопасных приложений. Иными словами, можно было написать анонимный и безопасный мессенджер (пример тому HLM), но нельзя было убрать анонимность из этого множества. От части это логичное поведение, ведь HL как-никак анонимная сеть. Противоречием же здесь являлось то, что раз сеть строится на независимых друг от друга сервисов, то чисто технически и теоретически должна была существовать возможность избавиться от сервиса представляющего анонимность. И такое решение нашлось.

a41fa9f265b521953e5b70b504b51168.png

Пара слов об используемых сервисах

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

Данный мессенджер будет завязан на использовании двух HL сервисов: HLT (traffic) и HLE (encryptor). Вкратце, HLT — это сервис-ретранслятор и сервис-хранилище сгенерированного трафика. Благодаря ему можно восстанавливать сообщения, которые уже были забыты сетью. Это свойство нам как раз в будущем и понадобится. Подробно о HLT можно почитать тут и тут.

Сервис HLE в свою очередь является очень простым приложением, он способен только шифровать и расшифровывать сообщения читаемые сетью HL, ни более ни менее. Но как раз последнего сервиса и не хватало для того, чтобы можно было писать исключительно клиент-безопасные приложения без свойства анонимности, либо более гибкие приложения, например с другим доказательством анонимности.

Реализация

1. Конфигурация

Первое с чего мы начнём — это с файла конфигурации. В нём должно быть три поля: hlt_host, hle_host и friends. Первые два указывают на адреса сервисов HLT и HLE соответственно. Сервис HLT может находиться в глобальной сети, в то время как HLE обязан находиться локально на компьютере. Суть заключается в том, что HLE обращается к приватному ключу, основному идентификатору в сети. Его потеря ведёт неминуемо к компрометации связи.

Последнее поле представляет собой мапу (словарь) по типу имя: публичный_ключ. Существование друзей приводит нас к сети типа friend-to-friend, когда участники коммуникации заранее выставляют с кем они могут общаться и от кого могут получать сообщения. Такое свойство позволяет избавляться от спама со стороны других участников сети, а также позволяет более легко идентифицировать абонентов по самостоятельно выставленному имени.

Итого, мы получаем примерно следующий конфигурационный файл:

hlt_host: localhost:9582
hle_host: localhost:9551
friends: 
  Alice: PubKey{3082020A02820201...3324D10203010001}

Единственное здесь неизвестное для нас значение — это сам публичный ключ, а именно способ его получения. Мы должны получить данный ключ от предполагаемого абонента, но как он должен его добыть? Здесь существует два способа:

  1. При запуске HLE, если последний не обнаружит наличие приватного ключа, то он сам его сгенерирует и загрузит в своё внутреннее состояние. Всё что остаётся — это запросить у HLE публичный ключ по его API.

    curl -i -X GET -H 'Accept: application/json' http://localhost:9551/api/service/pubkey

  2. Можно сгенерировать пару приватный / публичный ключ самостоятельно, используя приложение cmd/tools/keygen из репозитория go-peer. В таком случае достаточно будет прописать лишь длину RSA ключа. Длина ключа в сети HL равна 4096 бит. После запуска будет создано два файла priv.key и pub.key. Первый ключ подаём на вход HLE, а второй разглашаем своему собеседнику.

    go run . 4096

Теперь, всё что нам остаётся — это реализовать на питоне чтение конфигурационного файла. Для простоты я вынес прочитанные поля конфигурации в глобальные переменные. После прочтения, эти глобальные переменные только считаются программой. Следовательно, к возможному состоянию гонки данный подход приводить не будет.

import yaml

HLE_URL = "" # it is being overwritten
HLT_URL = "" # it is being overwritten
FRIENDS = {} # it is being overwritten

def init_load_config(cfg_path):
    global HLE_URL, HLT_URL, FRIENDS

    with open(cfg_path, "r") as stream:
        try:
            config_loaded = yaml.safe_load(stream)
        except yaml.YAMLError as e:
            print("@ failed load config")
            exit(3)
    
    HLT_URL = "http://" + config_loaded["hlt_host"]
    HLE_URL = "http://" + config_loaded["hle_host"]
    FRIENDS = config_loaded["friends"]

...

2. Чтение ввода

Мессенджер будет состоять из двух параллельных процедур: input_task и output_task. Первая процедура сводится к вводу данных / команд от самого пользователя. Команда будет всего одна: /friend. Она устанавливает друга которому мы будем отправлять сообщения. Вторая процедура сводится к получению данных из сети и к их выводу.

...

import multiprocessing

...

def main():
    init_load_config("config.yml")
    parallel_run(input_task, output_task)

def parallel_run(*fns):
    proc = []
    for fn in fns:
        p = multiprocessing.Process(target=fn)
        p.start()
        proc.append(p)
    for p in proc:
        p.join()

...

if __name__ == "__main__":
    main()

Сам код получился достаточно простым. Есть здесь лишь один момент, который стоит прояснить, а именно sys.stdin = open(0). Параллельная реализация запуска процедур сводится к использованию модуля multiprocessing, который в свою очередь не проталкивает стандартный поток ввода дальше в класс Proccess. Вследствие этого, необходимо явно указать данный поток (0) в самой параллельной процедуре. Без этой части кода input будет приводить к ошибкам EOFError: EOF when reading a line. Более подробно про это можно почитать тут.

...

import sys

def input_task():
    friend = ""

    sys.stdin = open(0)
    while True:
        msg = input("> ")
        if len(msg) == 0:
            print("@ got null message")
            continue

        if msg.startswith("/friend "):
            try:
                _friend = FRIENDS[msg[len("/friend "):].strip()]
            except KeyError:
                print("@ got invalid friend name")
                continue
            friend = _friend
            continue

        if friend == "":
            print("@ friend is null, use /friend to set")
            continue 

        ...

Далее, как только у нас уже есть публичный ключ друга и само сообщение, которое мы планируем отправить, мы должны обратиться к API сначала HLE, чтобы зашифровать сообщение, а далее к API HLT, чтобы отправить получившийся шифрованный результат в сеть.

        ...
      
        resp_hle = requests.post(
            HLE_URL+"/api/message/encrypt", 
            json={"public_key": friend, "hex_data": msg.encode("utf-8").hex()}
        )
        if resp_hle.status_code != 200:
            print("@ got response error from HLE (/api/message/encrypt)")
            continue 
        
        resp_hlt = requests.post(
            HLT_URL+"/api/network/message", 
            data=resp_hle.content
        )
        if resp_hlt.status_code != 200:
            print("@ got response error from HLT (/api/network/message)")
            continue 

  ...

Более подробно с API HLE и HLT можно ознакомиться в их README тут и здесь.

3. Чтение из сети

Чтение данных из сети уже является более сложной задачей, чем обычный ввод, но не чем-то сверхъестественным. Просто надо будет чаще обращаться к API HLT.

Для начала нам необходимо будет получить от HLT настройку того, сколько сам HLT сервис способен хранить на своей стороне сообщений (messages_capacity). Т.к. множество HLT сервисов представляют собой децентрализованную сеть, то настройки могут соответственно различаться. Кто-то может хранить тысячи сообщений, а кто-то миллион. После превышения заданного количества, сервис HLT начинает перезатирать старые сообщения новыми.

...

import requests, json

def output_task():
    # GET SETTING = MESSAGES_CAPACITY
    resp_hlt = requests.get(
        HLT_URL+"/api/config/settings"
    )
    if resp_hlt.status_code != 200:
        print("@ got response error from HLT (/api/config/settings)")
        exit(1)
    
    try:
        messages_capacity = json.loads(resp_hlt.content)["messages_capacity"]
    except ValueError:
        print("@ got response invalid data from HLT (/api/config/settings)")
        exit(2)
    
    ...

После получения ограничения, нам необходимо выставить текущий указатель (в терминологии HL) на котором находится сам HLT. Указатель — это индекс по массиву сообщений, который только увеличивается, но в кольце. Иными словами, достигая messages_capacity, указатель будет становиться вновь нулевым. Постоянное инкрементирование описывается простой формулой: (i + 1) mod messages_capacity.

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

import time 

...
    ...
  
    global_pointer = -1
    while True:
        # GET INITIAL POINTER OF MESSAGES
        resp_hlt = requests.get(
            HLT_URL+"/api/storage/pointer"
        )
        if resp_hlt.status_code != 200:
            print("@ got response error from HLT (/api/storage/pointer)")
            time.sleep(1)
            continue 

        try:
            pointer = int(resp_hlt.content)
        except ValueError:
            print("@ got response invalid data from HLT (/api/storage/pointer)")
            time.sleep(1)
            continue

        if global_pointer == -1:
            global_pointer = pointer

        if global_pointer == pointer:
            time.sleep(1)
            continue
    
        # GET ALL MESSAGES FROM CURRENT POINTER TO GOT POINTER
        while global_pointer != pointer:
            global_pointer = (global_pointer + 1) % messages_capacity

            ...

На основе логики указателей мы пишем финальную логику — логику получения и расшифрования сообщения. Сначала по указателю получаем хеш от HLT, далее по хешу получаем сообщение также от HLT, передаём полученное сообщение на HLE для расшифрования.

После расшифрования нам необходимо проверить публичный ключ отправителя. Если отправитель находится в нашем списке друзей, тогда печатаем полученное сообщение, иначе игнорируем.

import hashlib 

...
            ...
  
            resp_hlt = requests.get(
                HLT_URL+"/api/storage/hashes?id="+f"{(global_pointer - 1) % messages_capacity}"
            )
            if resp_hlt.status_code != 200:
                break 
            
            resp_hlt = requests.get(
                HLT_URL+"/api/network/message?hash="+resp_hlt.content.decode("utf8")
            )
            if resp_hlt.status_code != 200:
                break 

            # TRY DECRYPT GOT MESSAGE
            resp_hle = requests.post(
                HLE_URL+"/api/message/decrypt", 
                data=resp_hlt.content
            )
            if resp_hle.status_code != 200:
                continue 

            try:
                json_resp = json.loads(resp_hle.content)
            except ValueError:
                print("@ got response invalid data from HLE (/api/message/decrypt)")
                continue
        
            # CHECK GOT PUBLIC KEY IN FRIENDS LIST
            user_id = hashlib.sha256(json_resp["public_key"].encode('utf-8')).hexdigest()
            friend_name = get_friend_name(user_id)
            if friend_name == "":
                continue 

            got_data = bytes.fromhex(json_resp["hex_data"]).decode('utf-8')
            print(f"[{friend_name}]: {got_data}\n> ", end="")

def get_friend_name(user_id):
  for k, v in FRIENDS.items():
      friend_id = hashlib.sha256(v.encode('utf-8')).hexdigest()
      if user_id == friend_id:
          return k
  return ""

...

И это всё. Итоговая реализация получилась примерно в 160 строк кода.

Полный исходный код

import multiprocessing, sys, requests, time, json, hashlib, yaml

HLE_URL = "" # it is being overwritten
HLT_URL = "" # it is being overwritten
FRIENDS = {} # it is being overwritten

def main():
    init_load_config("config.yml")
    parallel_run(input_task, output_task)

def init_load_config(cfg_path):
    global HLE_URL, HLT_URL, FRIENDS

    with open(cfg_path, "r") as stream:
        try:
            config_loaded = yaml.safe_load(stream)
        except yaml.YAMLError as e:
            print("@ failed load config")
            exit(3)
    
    HLT_URL = "http://" + config_loaded["hlt_host"]
    HLE_URL = "http://" + config_loaded["hle_host"]
    FRIENDS = config_loaded["friends"]

def parallel_run(*fns):
    proc = []
    for fn in fns:
        p = multiprocessing.Process(target=fn)
        p.start()
        proc.append(p)
    for p in proc:
        p.join()

def output_task():
    # GET SETTING = MESSAGES_CAPACITY
    resp_hlt = requests.get(
        HLT_URL+"/api/config/settings"
    )
    if resp_hlt.status_code != 200:
        print("@ got response error from HLT (/api/config/settings)")
        exit(1)
    
    try:
        messages_capacity = json.loads(resp_hlt.content)["messages_capacity"]
    except ValueError:
        print("@ got response invalid data from HLT (/api/config/settings)")
        exit(2)
    
    global_pointer = -1
    while True:
        # GET INITIAL POINTER OF MESSAGES
        resp_hlt = requests.get(
            HLT_URL+"/api/storage/pointer"
        )
        if resp_hlt.status_code != 200:
            print("@ got response error from HLT (/api/storage/pointer)")
            time.sleep(1)
            continue 

        try:
            pointer = int(resp_hlt.content)
        except ValueError:
            print("@ got response invalid data from HLT (/api/storage/pointer)")
            time.sleep(1)
            continue

        if global_pointer == -1:
            global_pointer = pointer

        if global_pointer == pointer:
            time.sleep(1)
            continue
    
        # GET ALL MESSAGES FROM CURRENT POINTER TO GOT POINTER
        while global_pointer != pointer:
            global_pointer = (global_pointer + 1) % messages_capacity

            resp_hlt = requests.get(
                HLT_URL+"/api/storage/hashes?id="+f"{(global_pointer - 1) % messages_capacity}"
            )
            if resp_hlt.status_code != 200:
                break 
            
            resp_hlt = requests.get(
                HLT_URL+"/api/network/message?hash="+resp_hlt.content.decode("utf8")
            )
            if resp_hlt.status_code != 200:
                break 

            # TRY DECRYPT GOT MESSAGE
            resp_hle = requests.post(
                HLE_URL+"/api/message/decrypt", 
                data=resp_hlt.content
            )
            if resp_hle.status_code != 200:
                continue 

            try:
                json_resp = json.loads(resp_hle.content)
            except ValueError:
                print("@ got response invalid data from HLE (/api/message/decrypt)")
                continue
        
            # CHECK GOT PUBLIC KEY IN FRIENDS LIST
            user_id = hashlib.sha256(json_resp["public_key"].encode('utf-8')).hexdigest()
            friend_name = get_friend_name(user_id)
            if friend_name == "":
                continue 

            got_data = bytes.fromhex(json_resp["hex_data"]).decode('utf-8')
            print(f"[{friend_name}]: {got_data}\n> ", end="")

def input_task():
    friend = ""

    sys.stdin = open(0)
    while True:
        msg = input("> ")
        if len(msg) == 0:
            print("@ got null message")
            continue

        if msg.startswith("/friend "):
            try:
                _friend = FRIENDS[msg[len("/friend "):].strip()]
            except KeyError:
                print("@ got invalid friend name")
                continue
            friend = _friend
            continue

        if friend == "":
            print("@ friend is null, use /friend to set")
            continue 

        resp_hle = requests.post(
            HLE_URL+"/api/message/encrypt", 
            json={"public_key": friend, "hex_data": msg.encode("utf-8").hex()}
        )
        if resp_hle.status_code != 200:
            print("@ got response error from HLE (/api/message/encrypt)")
            continue 
        
        resp_hlt = requests.post(
            HLT_URL+"/api/network/message", 
            data=resp_hle.content
        )
        if resp_hlt.status_code != 200:
            print("@ got response error from HLT (/api/network/message)")
            continue 

def get_friend_name(user_id):
    for k, v in FRIENDS.items():
        friend_id = hashlib.sha256(v.encode('utf-8')).hexdigest()
        if user_id == friend_id:
            return k
    return ""

if __name__ == "__main__":
    main()

Запускаем локально и на проде

Теперь всё, что нам остаётся сделать — это запустить написанный скрипт. В зависимости от hlt_host мы можем запускать данный скрипт как в локальной среде, так и непосредственно на проде, посредством использования платформы Hidden Lake. Для начала давайте попробуем запустить всё это чудо в локальном окружении.

В репозитории go-peer уже подготовлен пример с автоматическим запуском двух узлов, то есть двух HLE сервисов с двумя разными config.yml файлами. HLT сервис при этом поднимается один, но это не есть необходимость. HLT сервисы достаточно гибко могут связываться между собой за счёт того, что фактически они являются также ретрансляторами принимаемых сообщений. Поэтому проблема централизации здесь особо не стоит.

Для запуска примера нам необходимо сначала скачать сам репозиторий go-peer, далее перейти в директорию examples/secure_messenger и прописать команду make. После данного действия поднимутся два HLE сервиса и один HLT. Пример требует компиляции этих двух сервисов, что, следовательно, требует компилятора языка Go. Если у вас нет компилятора, то обе программы можно скачать с релизной версии go-peer по ссылке.

$ cd examples/secure_messenger
$ make
... # Логи очистки, компиляции, копирования
[INFO] 2023/12/25 01:41:34 HLE is running...
[INFO] 2023/12/25 01:41:34 HLE is running...
[INFO] 2023/12/25 01:41:34 HLT is running...

Теперь создаём два терминала. Из-под одного терминала переходим в директорию examples/secure_messenger/node1, из-под другого в директорию examples/secure_messenger/node2. Прописываем на первом узле команду /friend Alice, на втором команду /friend Bob. После переключения мы можем начинать писать друг другу сообщения. Само же переключение говорит лишь о том кому мы будем писать сообщение. На получение сообщений оно никак не влияет.

# Терминал#1
$ python3 main.py                                                                                INT ✘  16s  
> /friend Alice
> hello
> [Alice]: world
>

# Терминал#2
$ python3 main.py                                                                                INT ✘  13s  
> /friend Bob
> [Bob]: hello
> world
>

Пример чата с двумя узлами

Пример чата с двумя узлами

После успешного локального запуска мы также можем проверить работу нашего приложения уже на проде, посредством использования готовой Hidden Lake сети. Для этого надо взять список существующих HLT сервисов. Все они находятся в README.

Существующие HLT сервисы в сети Hidden Lake

Существующие HLT сервисы в сети Hidden Lake

Чтобы переключиться на сторонний сервис, нам необходимо поменять в конфигурациях config.yml и hle.yml по одному полю. Возьмём пятый сервис HLTs, используемый только под хранение сообщений. В config.yml меняем поле hlt_host с localhost:6582 на 195.43.4.253:9582. В hle.yml необходимо, чтобы поле network_key соответствовало j2BR39JfDf7Bajx3.

Логи HLE сервисов. WARN - безуспешная попытка расшифровать сообщение. Наше приложение может получать сообщения не нам адресуемые, поэтому мы их не сможем прочитать.

Логи HLE сервисов. WARN — безуспешная попытка расшифровать сообщение. Наше приложение может получать сообщения не нам адресуемые, поэтому мы их не сможем прочитать.

После этих изменений перезапускаем HLE сервисы. Это можно сделать при помощи make clean и снова make. Всё, теперь мы также отправляем и получаем сообщения, но уже будучи связанными с внешним сервисом HLT.

Заключение

Таким образом, нами был написан мессенджер с end-to-end шифрованием поверх Hidden Lake сервисов на языке программирования Python. Весь исходный код данного мессенджер можно найти тут. Запустить и проверить его работу с локально созданными двумя узлами можно тут.

© Habrahabr.ru