Будучи разработчиком PyGOST библиотеки (ГОСТовые криптографические примитивы на чистом Python), я нередко получаю вопросы о том, как на коленке реализовать простейший безопасный обмен сообщениями. Многие считают прикладную криптографию достаточно простой штукой, и .encrypt() вызова у блочного шифра будет достаточно для безопасной отсылки по каналу связи. Другие же считают, что прикладная криптография — удел немногих, и приемлемо, что богатые компании типа Telegram с олимпиадниками-математиками не могут реализовать безопасный протокол.

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

Hearing

В статье будет написан peer-to-peer, friend-to-friend, end-to-end зашифрованный instant messenger с SIGMA-I протоколом аутентификации и согласования ключей (на базе которого реализован IPsec IKE), используя исключительно ГОСТовые криптографические алгоритмы PyGOST библиотеки и ASN.1 кодирование сообщений библиотекой PyDERASN (про которую я уже писал раньше). Необходимое условие: он должен быть настолько прост, чтобы его можно было написать с нуля за один вечер (или рабочий день), иначе это уже не простая программа. В ней наверняка есть ошибки, излишние сложности, недочёты, плюс это моя первая программа с использованием asyncio библиотеки.

Дизайн IM


Для начала, надо понять, как будет выглядеть наш IM. Для простоты, пускай это будет peer-to-peer сеть, без какого-либо обнаружения участников. Собственноручно будем указывать, к какому адресу: порту подключаться для общения с собеседником.

Я понимаю, что на данный момент, предположение о доступности прямой связи между двумя произвольными компьютерами — существенное ограничение применимости IM на практике. Но чем больше разработчиков будут реализовывать всякие NAT-traversal костыли, тем дольше мы так и будем оставаться в IPv4 Интернете, с удручающей вероятностью связи между произвольными компьютерами. Ну сколько можно терпеть отсутствие IPv6 дома и на работе?

У нас будет friend-to-friend сеть: все возможные собеседники заранее должны быть известны. Во-первых, это сильно всё упрощает: представились, нашли или не нашли имя/ключ, отключились или продолжаем работу, зная собеседника. Во-вторых, в общем случае, это безопасно и исключает множество атак.

Интерфейс IM-а будет близок к классическим решениям suckless-проектов, которые мне очень нравятся своим минимализмом и Unix-way философией. IM программа для каждого собеседника создаёт директорию с тремя Unix domain sockets:

  • in — в него записываются отправляемые собеседнику сообщения;
  • out — из него читаются принимаемые от собеседника сообщения;
  • state — читая из него, мы узнаём, подключён ли сейчас собеседник, адрес/порт подключения.

Кроме того, создаётся conn сокет, записав в который хост порт, мы инициируем подключение к удалённому собеседнику.

|-- alice
|   |-- in
|   |-- out
|   `-- state
|-- bob
|   |-- in
|   |-- out
|   `-- state
`- conn

Такой подход позволяет делать независимые реализации IM транспорта и пользовательского интерфейса, ведь на вкус и цвет товарища нет, каждому не угодишь. Используя tmux и/или multitail, можно получить многооконный интерфейс с синтаксической подсветкой. А с помощью rlwrap можно получить GNU Readline-совместимую строку для ввода сообщений.

На самом деле suckless проекты используют FIFO-файлы. Лично я не смог понять, как в asyncio работать с файлами конкурентно без собственноручной подложки из выделенных тредов (для таких вещей давно использую язык Go). Поэтому решил обойтись Unix domain сокетами. К сожалению, это лишает возможности сделать echo 2001:470:dead::babe 6666 > conn. Я решил эту проблему, используя socat: echo 2001:470:dead::babe 6666 | socat — UNIX-CONNECT:conn, socat READLINE UNIX-CONNECT:alice/in.

Первоначальный небезопасный протокол


В качестве транспорта используется TCP: он гарантирует доставку и её порядок. UDP не гарантирует ни того, ни другого (что было бы полезным, когда применится криптография), а поддержки SCTP в Python из коробки нет.

К сожалению, в TCP нет понятия сообщения, а только потока байт. Поэтому необходимо придумать формат для сообщений, чтобы их можно было разделять между собой в этом потоке. Можем условиться использовать символ перевода строки. Для начала подойдёт, однако, когда мы начнём шифровать наши сообщения, этот символ может появиться где угодно в шифротексте. В сетях поэтому популярны протоколы, отправляющие сначала длину сообщения в байтах. Например, в Python из коробки есть xdrlib, позволяющая работать с подобным форматом XDR.

Мы не будем правильно и эффективно работать с TCP чтением — упростим код. Читаем в бесконечном цикле данные из сокета, пока не декодируем полное сообщение. В качестве формата для такого подхода можно использовать и JSON с XML. Но когда добавится криптография, то данные придётся подписывать и аутентифицировать — а это потребует байт-в-байт идентичного представления объектов, чего не обеспечивают JSON/XML (dumps результат может отличаться).

XDR подходит для такой задачи, однако я выбираю ASN.1 с DER-кодированием и PyDERASN библиотеку, так как на руках у нас будут высокоуровневые объекты, с которыми часто приятнее и удобнее работать. В отличии от schemaless bencode, MessagePack или CBOR, ASN.1 автоматически проверит данные напротив жёстко заданной схемы.

# Msg ::= CHOICE {
#       text      MsgText,
#       handshake [0] EXPLICIT MsgHandshake }
class Msg(Choice):
    schema = ((
        ("text", MsgText()),
        ("handshake", MsgHandshake(expl=tag_ctxc(0))),
    ))

# MsgText ::= SEQUENCE {
#       text UTF8String (SIZE(1..MaxTextLen))}
class MsgText(Sequence):
    schema = ((
        ("text", UTF8String(bounds=(1, MaxTextLen))),
    ))

# MsgHandshake ::= SEQUENCE {
#       peerName UTF8String (SIZE(1..256)) }
class MsgHandshake(Sequence):
    schema = ((
        ("peerName", UTF8String(bounds=(1, 256))),
    ))

Принимаемым сообщением будет Msg: либо текстовое MsgText (пока с одним текстовым полем), либо сообщение рукопожатия MsgHandshake (в котором передаётся имя собеседника). Сейчас выглядит переусложнённым, но это задел на будущее.

     ------¬            ------¬
     ¦PeerA¦            ¦PeerB¦
     L--T---            L--T---
        ¦MsgHandshake(IdA) ¦
        ¦----------------->¦
        ¦                  ¦
        ¦MsgHandshake(IdB) ¦
        ¦<-----------------¦
        ¦                  ¦
        ¦    MsgText()     ¦
        ¦----------------->¦
        ¦                  ¦
        ¦    MsgText()     ¦
        ¦<-----------------¦
        ¦                  ¦


IM без криптографии


Как я уже говорил, для всех операций с сокетами будет использоваться asyncio библиотека. Объявим, что мы ожидаем в момент запуска:

parser = argparse.ArgumentParser(description="GOSTIM")
parser.add_argument(
    "--our-name",
    required=True,
    help="Our peer name",
)
parser.add_argument(
    "--their-names",
    required=True,
    help="Their peer names, comma-separated",
)
parser.add_argument(
    "--bind",
    default="::1",
    help="Address to listen on",
)
parser.add_argument(
    "--port",
    type=int,
    default=6666,
    help="Port to listen on",
)
args = parser.parse_args()
OUR_NAME = UTF8String(args.our_name)
THEIR_NAMES = set(args.their_names.split(","))

Задаётся собственное имя (--our-name alice). Через запятую перечисляются все ожидаемые собеседники (--their-names bob,eve). Для каждого из собеседников создаётся директория с Unix сокетами, а также по корутине на каждый in, out, state:

for peer_name in THEIR_NAMES:
    makedirs(peer_name, mode=0o700, exist_ok=True)
    out_queue = asyncio.Queue()
    OUT_QUEUES[peer_name] = out_queue
    asyncio.ensure_future(asyncio.start_unix_server(
        partial(unixsock_out_processor, out_queue=out_queue),
        path.join(peer_name, "out"),
    ))
    in_queue = asyncio.Queue()
    IN_QUEUES[peer_name] = in_queue
    asyncio.ensure_future(asyncio.start_unix_server(
        partial(unixsock_in_processor, in_queue=in_queue),
        path.join(peer_name, "in"),
    ))
    asyncio.ensure_future(asyncio.start_unix_server(
        partial(unixsock_state_processor, peer_name=peer_name),
        path.join(peer_name, "state"),
    ))
asyncio.ensure_future(asyncio.start_unix_server(unixsock_conn_processor, "conn"))

Приходящие от пользователя сообщения из in сокета отправляются в IN_QUEUES очереди:

async def unixsock_in_processor(reader, writer, in_queue: asyncio.Queue) -> None:
    while True:
        text = await reader.read(MaxTextLen)
        if text == b"":
            break
        await in_queue.put(text.decode("utf-8"))

Приходящие от собеседников сообщения отправляются в OUT_QUEUES очереди, из которых данные записываются в out сокет:

async def unixsock_out_processor(reader, writer, out_queue: asyncio.Queue) -> None:
    while True:
        text = await out_queue.get()
        writer.write(("[%s] %s" % (datetime.now(), text)).encode("utf-8"))
        await writer.drain()

При чтении из state сокета программа ищет в PEER_ALIVE словаре адрес собеседника. Если подключения к собеседнику ещё нет, то записывается пустая строка.

async def unixsock_state_processor(reader, writer, peer_name: str) -> None:
    peer_writer = PEER_ALIVES.get(peer_name)
    writer.write(
        b"" if peer_writer is None else (" ".join([
            str(i) for i in peer_writer.get_extra_info("peername")[:2]
        ]).encode("utf-8") + b"\n")
    )
    await writer.drain()
    writer.close()

При записи адреса в conn сокет запускается функция «инициатора» соединения:

async def unixsock_conn_processor(reader, writer) -> None:
    data = await reader.read(256)
    writer.close()
    host, port = data.decode("utf-8").split(" ")
    await initiator(host=host, port=int(port))

Рассмотрим инициатора. Сначала он, очевидно, открывает соединение до указанного хоста/порта и отправляет handshake сообщение со своим именем:

 130 async def initiator(host, port):
 131     _id = repr((host, port))
 132     logging.info("%s: dialing", _id)
 133     reader, writer = await asyncio.open_connection(host, port)
 134     # Handshake message {{{
 135     writer.write(Msg(("handshake", MsgHandshake((
 136         ("peerName", OUR_NAME),
 137     )))).encode())
 138     # }}}
 139     await writer.drain()

Затем ждёт ответа от удалённой стороны. Пытается декодировать пришедший ответ по Msg ASN.1 схеме. Предполагаем, что всё сообщение будет отправлено одним TCP-сегментом и мы атомарно его получим при вызове .read(). Проверяем, что мы получили именно handshake сообщение.

 141     # Wait for Handshake message {{{
 142     data = await reader.read(256)
 143     if data == b"":
 144         logging.warning("%s: no answer, disconnecting", _id)
 145         writer.close()
 146         return
 147     try:
 148         msg, _ = Msg().decode(data)
 149     except ASN1Error:
 150         logging.warning("%s: undecodable answer, disconnecting", _id)
 151         writer.close()
 152         return
 153     logging.info("%s: got %s message", _id, msg.choice)
 154     if msg.choice != "handshake":
 155         logging.warning("%s: unexpected message, disconnecting", _id)
 156         writer.close()
 157         return
 158     # }}}

Проверяем, что пришедшее имя собеседника нам известно. Если нет, то рвём соединение. Проверяем, не было ли у нас уже установлено с ним соединение (собеседник вновь дал команду на подключение к нам) и закрываем его. В IN_QUEUES очередь помещаются Python-строки с текстом сообщения, но имеется особое значение None, сигнализирующее msg_sender корутину прекратить работу, чтобы она забыла о своём writer, связанным с устаревшим TCP-соединением.

 159     msg_handshake = msg.value
 160     peer_name = str(msg_handshake["peerName"])
 161     if peer_name not in THEIR_NAMES:
 162         logging.warning("unknown peer name: %s", peer_name)
 163         writer.close()
 164         return
 165     logging.info("%s: session established: %s", _id, peer_name)
 166     # Run text message sender, initialize transport decoder {{{
 167     peer_alive = PEER_ALIVES.pop(peer_name, None)
 168     if peer_alive is not None:
 169         peer_alive.close()
 170         await IN_QUEUES[peer_name].put(None)
 171     PEER_ALIVES[peer_name] = writer
 172     asyncio.ensure_future(msg_sender(peer_name, writer))
 173     # }}}

msg_sender принимает исходящие сообщения (подкладываемые в очередь из in сокета), сериализует их в MsgText сообщение и отправляет по TCP-соединению. Оно может оборваться в любой момент — это мы явно перехватываем.

async def msg_sender(peer_name: str, writer) -> None:
    in_queue = IN_QUEUES[peer_name]
    while True:
        text = await in_queue.get()
        if text is None:
            break
        writer.write(Msg(("text", MsgText((
            ("text", UTF8String(text)),
        )))).encode())
        try:
            await writer.drain()
        except ConnectionResetError:
            del PEER_ALIVES[peer_name]
            return
        logging.info("%s: sent %d characters message", peer_name, len(text))

В конце инициатор входит в бесконечный цикл чтения сообщений из сокета. Проверяет, текстовые ли это сообщения, и помещает в OUT_QUEUES очередь, из которой они будут отправлены в out сокет соответствующего собеседника. Почему нельзя просто делать .read() и декодировать сообщение? Потому что не исключена ситуация, когда несколько сообщений от пользователя будут агрегированы в буфере операционной системы и отправлены одним TCP-сегментом. Декодировать-то мы сможем первое, а дальше в буфере может остаться часть от последующего. При любой нештатной ситуации мы закрываем TCP-соединение и останавливаем msg_sender корутину (посылкой None в OUT_QUEUES очередь).

 174     buf = b""
 175     # Wait for test messages {{{
 176     while True:
 177         data = await reader.read(MaxMsgLen)
 178         if data == b"":
 179             break
 180         buf += data
 181         if len(buf) > MaxMsgLen:
 182             logging.warning("%s: max buffer size exceeded", _id)
 183             break
 184         try:
 185             msg, tail = Msg().decode(buf)
 186         except ASN1Error:
 187             continue
 188         buf = tail
 189         if msg.choice != "text":
 190             logging.warning("%s: unexpected %s message", _id, msg.choice)
 191             break
 192         try:
 193             await msg_receiver(msg.value, peer_name)
 194         except ValueError as err:
 195             logging.warning("%s: %s", err)
 196             break
 197     # }}}
 198     logging.info("%s: disconnecting: %s", _id, peer_name)
 199     IN_QUEUES[peer_name].put(None)
 200     writer.close()

  66 async def msg_receiver(msg_text: MsgText, peer_name: str) -> None:
  67     text = str(msg_text["text"])
  68     logging.info("%s: received %d characters message", peer_name, len(text))
  69     await OUT_QUEUES[peer_name].put(text)

Вернёмся к основному коду. После создания всех корутин в момент запуска программы мы стартуем TCP-сервер. На каждое установленное соединение он создаёт responder (ответчик) корутину.

logging.basicConfig(
    level=logging.INFO,
    format="%(levelname)s %(asctime)s: %(funcName)s: %(message)s",
)
loop = asyncio.get_event_loop()
server = loop.run_until_complete(asyncio.start_server(responder, args.bind, args.port))
logging.info("Listening on: %s", server.sockets[0].getsockname())
loop.run_forever()

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

  72 async def responder(reader, writer):
  73     _id = writer.get_extra_info("peername")
  74     logging.info("%s: connected", _id)
  75     buf = b""
  76     msg_expected = "handshake"
  77     peer_name = None
  78     while True:
  79         # Read until we get Msg message {{{
  80         data = await reader.read(MaxMsgLen)
  81         if data == b"":
  82             logging.info("%s: closed connection", _id)
  83             break
  84         buf += data
  85         if len(buf) > MaxMsgLen:
  86             logging.warning("%s: max buffer size exceeded", _id)
  87             break
  88         try:
  89             msg, tail = Msg().decode(buf)
  90         except ASN1Error:
  91             continue
  92         buf = tail
  93         # }}}
  94         if msg.choice != msg_expected:
  95             logging.warning("%s: unexpected %s message", _id, msg.choice)
  96             break
  97         if msg_expected == "text":
  98             try:
  99                 await msg_receiver(msg.value, peer_name)
 100             except ValueError as err:
 101                 logging.warning("%s: %s", err)
 102                 break
 103         # Process Handshake message {{{
 104         elif msg_expected == "handshake":
 105             logging.info("%s: got %s message", _id, msg_expected)
 106             msg_handshake = msg.value
 107             peer_name = str(msg_handshake["peerName"])
 108             if peer_name not in THEIR_NAMES:
 109                 logging.warning("unknown peer name: %s", peer_name)
 110                 break
 111             writer.write(Msg(("handshake", MsgHandshake((
 112                 ("peerName", OUR_NAME),
 113             )))).encode())
 114             await writer.drain()
 115             logging.info("%s: session established: %s", _id, peer_name)
 116             peer_alive = PEER_ALIVES.pop(peer_name, None)
 117             if peer_alive is not None:
 118                 peer_alive.close()
 119                 await IN_QUEUES[peer_name].put(None)
 120             PEER_ALIVES[peer_name] = writer
 121             asyncio.ensure_future(msg_sender(peer_name, writer))
 122             msg_expected = "text"
 123         # }}}
 124     logging.info("%s: disconnecting", _id)
 125     if msg_expected == "text":
 126         IN_QUEUES[peer_name].put(None)
 127     writer.close()

Безопасный протокол


Пришло время обезопасить наше общение. Что же мы подразумеваем под безопасностью и что хотим:

  • конфиденциальность передаваемых сообщений;
  • аутентичность и целостность передаваемых сообщений — их изменение должно быть обнаружено;
  • защита от атак перепроигрывания (replay attack) — факт пропажи или повтора сообщений должен быть обнаружен (и мы решаем обрывать соединение);
  • идентификация и аутентификация собеседников по заранее вбитым публичным ключам — мы уже решили ранее, что делаем friend-to-friend сеть. Только после аутентификации мы поймём, с кем общаемся;
  • наличие perfect forward secrecy свойства (PFS) — компрометация нашего долгоживущего ключа подписи не должна приводить к возможности чтения всей предыдущей переписки. Запись перехваченного трафика становится бесполезной;
  • действительность/валидность сообщений (транспортных и рукопожатия) только в пределах одной TCP-сессии. Вставка корректно подписанных/аутентифицированных сообщений из другой сессии (даже с этим же собеседником) не должна быть возможной;
  • пассивный наблюдатель не должен видеть ни идентификаторов пользователей, ни передаваемых долгоживущих публичных ключей, ни хэшей от них. Некая анонимность от пассивного наблюдателя.

Удивительно, но этот минимум практически все хотят иметь в любом протоколе рукопожатия, и крайне мало из перечисленного в итоге выполняется для «доморощенных» протоколов. Вот и сейчас не будем изобретать нового. Я бы однозначно рекомендовал использовать Noise framework для построения протоколов, но выберем что-то попроще.

Наиболее популярны два протокола:

  • TLS — сложнейший протокол с длинной историей багов, косяков, уязвимостей, плохой продуманности, сложности и недочётов (впрочем, к TLS 1.3 это мало относится). Но не рассматриваем его из-за переусложнённости.
  • IPsec с IKE — не имеют серьёзных криптографических проблем, хотя тоже не просты. Если почитать про IKEv1 и IKEv2, то их истоком являются STS, ISO/IEC IS 9798-3 и SIGMA (SIGn-and-MAc) протоколы — достаточно простые для реализации за один вечер.

Чем SIGMA, как последнее звено развития STS/ISO протоколов, хорош? Он удовлетворяет всем нашим требованиям (в том числе «скрытия» идентификаторов собеседников), не имеет известных криптографических проблем. Он минималистичен — удаление хотя бы одного элемента из сообщения протокола приведёт к его небезопасности.

Давайте пройдёмся от простейшего доморощенного протокола до SIGMA. Самая базовая интересующая нас операция это согласование ключей: функция, на выходе которой оба участника получат одно и то же значение, которое можно будет использовать в качестве симметричного ключа. Не вдаваясь в подробности: каждая из сторон генерирует эфемерную (использующуюся только в пределах одной сессии) ключевую пару (публичный и приватный ключи), обмениваются публичными ключами, вызывают функцию согласования, на вход которой передают свой приватный ключ и публичный ключ собеседника.

------¬          ------¬
¦PeerA¦          ¦PeerB¦
L--T---          L--T---
   ¦   IdA, PubA    ¦ г====================¬
   ¦--------------->¦ ¦PrvA, PubA = DHgen()¦
   ¦                ¦ L====================-
   ¦   IdB, PubB    ¦ г====================¬
   ¦<---------------¦ ¦PrvB, PubB = DHgen()¦
   ¦                ¦ L====================-
   ----¬    г=======¦============¬
       ¦    ¦Key = DH(PrvA, PubB)¦
   <----    L=======T============-
   ¦                ¦
   ¦                ¦


Любой может встрять-посередине и заменить публичные ключи своими собственными — в данном протоколе нет аутентификации собеседников. Добавим подпись долгоживущими ключами.

------¬                            ------¬
¦PeerA¦                            ¦PeerB¦
L--T---                            L--T---
   ¦IdA, PubA, sign(SignPrvA, (PubA)) ¦ г===========================¬
   ¦--------------------------------->¦ ¦SignPrvA, SignPubA = load()¦
   ¦                                  ¦ ¦PrvA, PubA = DHgen()       ¦
   ¦                                  ¦ L===========================-
   ¦IdB, PubB, sign(SignPrvB, (PubB)) ¦ г===========================¬
   ¦<---------------------------------¦ ¦SignPrvB, SignPubB = load()¦
   ¦                                  ¦ ¦PrvB, PubB = DHgen()       ¦
   ¦                                  ¦ L===========================-
   ----¬    г=====================¬   ¦
       ¦    ¦verify(SignPubB, ...)¦   ¦
   <----    ¦Key = DH(PrvA, PubB) ¦   ¦
   ¦        L=====================-   ¦
   ¦                                  ¦


Такая подпись не подойдёт, так как она не привязана к конкретной сессии. Такие сообщения «подойдут» и для сессий с другими участниками. Подписываться должен весь контекст. Это вынуждает также добавить посылку ещё одного сообщения от A.

Кроме того, критично добавить под подпись и собственный идентификатор, так как, в противном случае мы можем подменить IdXXX и переподписать сообщение ключом другого известного собеседника. Для предотвращения reflection атак, необходимо, чтобы элементы под подписью находились в чётко заданных местах по своему смыслу: если A подписывает (PubA, PubB), то B должен подписывать (PubB, PubA). Это ещё и говорит о важности выбора структуры и формата сериализованных данных. Например, множества в ASN.1 DER кодировании сортируются: SET OF(PubA, PubB) будет идентичен SET OF(PubB, PubA).

------¬                                       ------¬
¦PeerA¦                                       ¦PeerB¦
L--T---                                       L--T---
   ¦                 IdA, PubA                   ¦ г===========================¬
   ¦-------------------------------------------->¦ ¦SignPrvA, SignPubA = load()¦
   ¦                                             ¦ ¦PrvA, PubA = DHgen()       ¦
   ¦                                             ¦ L===========================-
   ¦IdB, PubB, sign(SignPrvB, (IdB, PubA, PubB)) ¦ г===========================¬
   ¦<--------------------------------------------¦ ¦SignPrvB, SignPubB = load()¦
   ¦                                             ¦ ¦PrvB, PubB = DHgen()       ¦
   ¦                                             ¦ L===========================-
   ¦     sign(SignPrvA, (IdA, PubB, PubA))       ¦ г=====================¬
   ¦-------------------------------------------->¦ ¦verify(SignPubB, ...)¦
   ¦                                             ¦ ¦Key = DH(PrvA, PubB) ¦
   ¦                                             ¦ L=====================-
   ¦                                             ¦


Однако мы всё ещё не «доказали» что выработали одинаковый общий ключ для этой сессии. В принципе, можно обойтись и без этого шага — первое же транспортное сообщение будет невалидным, но мы хотим чтобы, когда рукопожатие завершилось, то были бы уверены, что всё действительно согласовано. На данный момент у нас на руках ISO/IEC IS 9798-3 протокол.

Мы могли бы подписывать и сам выработанный ключ. Это опасно, так как не исключено, что в используемом алгоритме подписи могут быть утечки (пускай биты-на-подпись, но всё же утечки). Можно подписывать хэш от выработанного ключа, но утечка даже хэша от выработанного ключа может иметь ценность при brute-force атаке на функцию выработки. SIGMA использует MAC функцию, аутентифицирующую идентификатор отправителя.

------¬                                            ------¬
¦PeerA¦                                            ¦PeerB¦
L--T---                                            L--T---
   ¦                    IdA, PubA                     ¦ г===========================¬
   ¦------------------------------------------------->¦ ¦SignPrvA, SignPubA = load()¦
   ¦                                                  ¦ ¦PrvA, PubA = DHgen()       ¦
   ¦                                                  ¦ L===========================-
   ¦IdB, PubB, sign(SignPrvB, (PubA, PubB)), MAC(IdB) ¦ г===========================¬
   ¦<-------------------------------------------------¦ ¦SignPrvB, SignPubB = load()¦
   ¦                                                  ¦ ¦PrvB, PubB = DHgen()       ¦
   ¦                                                  ¦ L===========================-
   ¦                                                  ¦ г=====================¬
   ¦     sign(SignPrvA, (PubB, PubA)), MAC(IdA)       ¦ ¦Key = DH(PrvA, PubB) ¦
   ¦------------------------------------------------->¦ ¦verify(Key, IdB)     ¦
   ¦                                                  ¦ ¦verify(SignPubB, ...)¦
   ¦                                                  ¦ L=====================-
   ¦                                                  ¦


В качестве оптимизации некоторые могут захотеть переиспользовать свои эфемерные ключи (что, конечно, плачевно для PFS). Например, мы сгенерировали ключевую пару, попытались подключиться, но TCP не был доступен или оборвался где-то на середине протокола. Жалко тратить потраченную энтропию и ресурсы процессора на новую пару. Поэтому введём так называемый cookie — псевдослучайное значение, которое защитит от возможных случайных replay атак при повторном использовании эфемерных публичных ключей. Из-за binding-а между cookie и эфемерным публичным ключом, публичный ключ противоположного участника можно убрать из подписи за ненадобностью.

------¬                                                                 ------¬
¦PeerA¦                                                                 ¦PeerB¦
L--T---                                                                 L--T---
   ¦                          IdA, PubA, CookieA                           ¦ г===========================¬
   ¦---------------------------------------------------------------------->¦ ¦SignPrvA, SignPubA = load()¦
   ¦                                                                       ¦ ¦PrvA, PubA = DHgen()       ¦
   ¦                                                                       ¦ L===========================-
   ¦IdB, PubB, CookieB, sign(SignPrvB, (CookieA, CookieB, PubB)), MAC(IdB) ¦ г===========================¬
   ¦<----------------------------------------------------------------------¦ ¦SignPrvB, SignPubB = load()¦
   ¦                                                                       ¦ ¦PrvB, PubB = DHgen()       ¦
   ¦                                                                       ¦ L===========================-
   ¦                                                                       ¦ г=====================¬
   ¦          sign(SignPrvA, (CookieB, CookieA, PubA)), MAC(IdA)           ¦ ¦Key = DH(PrvA, PubB) ¦
   ¦---------------------------------------------------------------------->¦ ¦verify(Key, IdB)     ¦
   ¦                                                                       ¦ ¦verify(SignPubB, ...)¦
   ¦                                                                       ¦ L=====================-
   ¦                                                                       ¦


Наконец, мы хотим получить приватность наших идентификаторов собеседников от пассивного наблюдателя. Для этого SIGMA предлагает сначала обменяться эфемерными ключами, выработать общий ключ, на котором зашифровать аутентифицирующие и идентифицирующие сообщения. SIGMA описывает два варианта:

  • SIGMA-I — защищает инициатора от активных атак, ответчика от пассивных: инициатор аутентифицирует ответчика и если что-то не сошлось, то свою идентификацию он не выдаёт. Ответчик же выдаёт свою идентификацию если с ним начать активный протокол. Пассивный наблюдатель ничего не узнает;
    SIGMA-R — защищает ответчика от активных атак, инициатора от пассивных. Всё с точностью до наоборот, но в этом протоколе уже четыре сообщения рукопожатия передаётся.


    Выбираем SIGMA-I как более похожий на то, что мы ожидаем от клиент-серверных привычных вещей: клиента узнает только аутентифицированный сервер, а сервер и так знают все. Плюс он проще в реализации из-за меньшего количества сообщений рукопожатия. Всё что мы вносим в протокол, так это шифрование части сообщения и перенос идентификатора A в шифрованную часть последнего сообщения:

    ------¬                                                                        ------¬
    ¦PeerA¦                                                                        ¦PeerB¦
    L--T---                                                                        L--T---
       ¦                                PubA, CookieA                                 ¦ г===========================¬
       ¦----------------------------------------------------------------------------->¦ ¦SignPrvA, SignPubA = load()¦
       ¦                                                                              ¦ ¦PrvA, PubA = DHgen()       ¦
       ¦                                                                              ¦ L===========================-
       ¦PubB, CookieB, Enc((IdB, sign(SignPrvB, (CookieA, CookieB, PubB)), MAC(IdB))) ¦ г===========================¬
       ¦<-----------------------------------------------------------------------------¦ ¦SignPrvB, SignPubB = load()¦
       ¦                                                                              ¦ ¦PrvB, PubB = DHgen()       ¦
       ¦                                                                              ¦ L===========================-
       ¦                                                                              ¦ г=====================¬
       ¦       Enc((IdA, sign(SignPrvA, (CookieB, CookieA, PubA)), MAC(IdA)))         ¦ ¦Key = DH(PrvA, PubB) ¦
       ¦----------------------------------------------------------------------------->¦ ¦verify(Key, IdB)     ¦
       ¦                                                                              ¦ ¦verify(SignPubB, ...)¦
       ¦                                                                              ¦ L=====================-
       ¦                                                                              ¦
    


    • Для подписи используется ГОСТ Р 34.10-2012 алгоритм с 256-бит ключами.
    • Для выработки общего ключа используется 34.10-2012 VKO.
    • В качестве MAC используется CMAC. Технически это особый режим работы блочного шифра, описанный в ГОСТ Р 34.13-2015. В качестве функции шифрования для этого режима — Кузнечик (34.12-2015).
    • В качестве идентификатора собеседника используется хэш от его публичного ключа. В качестве хэша применяется Стрибог-256 (34.11-2012 256 бит).


    После рукопожатия у нас будет согласован общий ключ. Его мы можем использовать для аутентифицированного шифрования транспортных сообщений. Эта часть совсем простая и в ней сложно ошибиться: инкрементируем счётчик сообщений, шифруем сообщение, аутентифицируем (MAC) счётчик и шифротекст, отправляем. При приёме сообщения проверяем что счётчик имеет ожидаемое значение, аутентифицируем шифротекст с счётчиком, дешифруем. Каким ключом шифровать сообщения рукопожатия, транспортные, каким аутентифицировать? Использовать один ключ для всех этих задач опасно и неразумно. Необходимо вырабатывать ключи, используя специализированные функции KDF (key derivation function). Опять же, не будем мудрить и что-то изобретать: HKDF давно известна, хорошо исследована и не имеет известных проблем. К сожалению, в родной библиотеке Python нет этой функции, поэтому используем hkdf пакет. HKDF внутри использует HMAC, который, в свою очередь, использует хэш-функцию. Пример реализации на Python на странице Wikipedia занимает считанные строки кода. Как и в случае с 34.10-2012, в качестве хэш-функции будем использовать Стрибог-256. Выход нашей функции согласования ключей будет называться сессионным ключом, из которого будут вырабатываться недостающие симметричные:

    kdf = Hkdf(None, key_session, hash=GOST34112012256)
    kdf.expand(b"handshake1-mac-identity")
    kdf.expand(b"handshake1-enc")
    kdf.expand(b"handshake1-mac")
    kdf.expand(b"handshake2-mac-identity")
    kdf.expand(b"handshake2-enc")
    kdf.expand(b"handshake2-mac")
    kdf.expand(b"transport-initiator-enc")
    kdf.expand(b"transport-initiator-mac")
    kdf.expand(b"transport-responder-enc")
    kdf.expand(b"transport-responder-mac")
    

    Структуры/схемы


    Рассмотрим какие же теперь ASN.1 структуры у нас получились для передачи всех этих данных:

    class Msg(Choice):
        schema = ((
            ("text", MsgText()),
            ("handshake0", MsgHandshake0(expl=tag_ctxc(0))),
            ("handshake1", MsgHandshake1(expl=tag_ctxc(1))),
            ("handshake2", MsgHandshake2(expl=tag_ctxc(2))),
        ))
    
    class MsgText(Sequence):
        schema = ((
            ("payload", MsgTextPayload()),
            ("payloadMac", MAC()),
        ))
    
    class MsgTextPayload(Sequence):
        schema = ((
            ("nonce", Integer(bounds=(0, float("+inf")))),
            ("ciphertext", OctetString(bounds=(1, MaxTextLen))),
        ))
    
    class MsgHandshake0(Sequence):
        schema = ((
            ("cookieInitiator", Cookie()),
            ("pubKeyInitiator", PubKey()),
        ))
    
    class MsgHandshake1(Sequence):
        schema = ((
            ("cookieResponder", Cookie()),
            ("pubKeyResponder", PubKey()),
            ("ukm", OctetString(bounds=(8, 8))),
            ("ciphertext", OctetString()),
            ("ciphertextMac", MAC()),
        ))
    
    class MsgHandshake2(Sequence):
        schema = ((
            ("ciphertext", OctetString()),
            ("ciphertextMac", MAC()),
        ))
    
    class HandshakeTBE(Sequence):
        schema = ((
            ("identity", OctetString(bounds=(32, 32))),
            ("signature", OctetString(bounds=(64, 64))),
            ("identityMac", MAC()),
        ))
    
    class HandshakeTBS(Sequence):
        schema = ((
            ("cookieTheir", Cookie()),
            ("cookieOur", Cookie()),
            ("pubKeyOur", PubKey()),
        ))
    
    class Cookie(OctetString): bounds = (16, 16)
    class PubKey(OctetString): bounds = (64, 64)
    class MAC(OctetString): bounds = (16, 16)
    

    HandshakeTBS — то, что будет подписываться (to be signed). HandshakeTBE — то, что будет зашифровано (to be encrypted). Обращаю внимание на поле ukm в MsgHandshake1. 34.10 VKO, для ещё большей рандомизации вырабатываемых ключей, включает параметр UKM (user keying material) — просто дополнительная энтропия.

    Добавление криптографии в код


    Рассмотрим лишь только внесённые к оригинальному коду изменения, так как каркас остался прежним (на самом деле, сначала была написана окончательная реализация, а потом из неё выпиливалась вся криптография).

    Так как аутентификация и идентификация собеседников будет проводится по публичным ключам, то теперь их надо где-то долговременно хранить. Для простоты используем JSON такого вида:

    {
        "our": {
            "prv": "21254cf66c15e0226ef2669ceee46c87b575f37f9000272f408d0c9283355f98",
            "pub": "938c87da5c55b27b7f332d91b202dbef2540979d6ceaa4c35f1b5bfca6df47df0bdae0d3d82beac83cec3e353939489d9981b7eb7a3c58b71df2212d556312a1"
        },
        "their": {
            "alice": "d361a59c25d2ca5a05d21f31168609deeec100570ac98f540416778c93b2c7402fd92640731a707ec67b5410a0feae5b78aeec93c4a455a17570a84f2bc21fce",
            "bob": "aade1207dd85ecd283272e7b69c078d5fae75b6e141f7649ad21962042d643512c28a2dbdc12c7ba40eb704af920919511180c18f4d17e07d7f5acd49787224a"
        }
    }
    

    our — наша ключевая пара, шестнадцатеричные приватный и публичные ключи. their — имена собеседников и их публичные ключи. Изменим аргументы командной строки и добавим постобработку JSON данных:

    from pygost import gost3410
    from pygost.gost34112012256 import GOST34112012256
    
    CURVE = gost3410.GOST3410Curve(
        *gost3410.CURVE_PARAMS["GostR3410_2001_CryptoPro_A_ParamSet"]
    )
    
    parser = argparse.ArgumentParser(description="GOSTIM")
    parser.add_argument(
        "--keys-gen",
        action="store_true",
        help="Generate JSON with our new keypair",
    )
    parser.add_argument(
        "--keys",
        default="keys.json",
        required=False,
        help="JSON with our and their keys",
    )
    parser.add_argument(
        "--bind",
        default="::1",
        help="Address to listen on",
    )
    parser.add_argument(
        "--port",
        type=int,
        default=6666,
        help="Port to listen on",
    )
    args = parser.parse_args()
    
    if args.keys_gen:
        prv_raw = urandom(32)
        pub = gost3410.public_key(CURVE, gost3410.prv_unmarshal(prv_raw))
        pub_raw = gost3410.pub_marshal(pub)
        print(json.dumps({
            "our": {"prv": hexenc(prv_raw), "pub": hexenc(pub_raw)},
            "their": {},
        }))
        exit(0)
    
    # Parse and unmarshal our and their keys {{{
    with open(args.keys, "rb") as fd:
        _keys = json.loads(fd.read().decode("utf-8"))
    KEY_OUR_SIGN_PRV = gost3410.prv_unmarshal(hexdec(_keys["our"]["prv"]))
    _pub = hexdec(_keys["our"]["pub"])
    KEY_OUR_SIGN_PUB = gost3410.pub_unmarshal(_pub)
    KEY_OUR_SIGN_PUB_HASH = OctetString(GOST34112012256(_pub).digest())
    for peer_name, pub_raw in _keys["their"].items():
        _pub = hexdec(pub_raw)
        KEYS[GOST34112012256(_pub).digest()] = {
            "name": peer_name,
            "pub": gost3410.pub_unmarshal(_pub),
        }
    # }}}
    

    Приватный ключ 34.10 алгоритма — случайное число. Размером 256-бит для 256-бит эллиптических кривых. PyGOST работает не с набором байт, а с большими числами, поэтому наш приватный ключ (urandom(32)) необходимо преобразовать в число, используя gost3410.prv_unmarshal(). Публичный ключ детерминировано вычисляется из приватного, используя gost3410.public_key(). Публичный ключ 34.10 — два больших числа, которые тоже нужно преобразовать в байтовую последовательность для удобства хранения и передачи, используя gost3410.pub_marshal().

    После чтения JSON файла, публичные ключи, соответственно, нужно преобразовать назад, используя gost3410.pub_unmarshal(). Так как нам будут приходить идентификаторы собеседников в виде хэша от публичного ключа, то их можно сразу же заранее вычислить и поместить в словарь для быстрого поиска. Стрибог-256 хэш это gost34112012256.GOST34112012256(), полностью удовлетворяющий hashlib интерфейсу хэш-функций.

    Как изменилась корутина инициатора? Всё, как по схеме рукопожатия: генерируем cookie (128-бит вполне предостаточно), эфемерную ключевую пару 34.10, которая будет использоваться для VKO функции согласования ключей.

     395 async def initiator(host, port):
     396     _id = repr((host, port))
     397     logging.info("%s: dialing", _id)
     398     reader, writer = await asyncio.open_connection(host, port)
     399     # Generate our ephemeral public key and cookie, send Handshake 0 message {{{
     400     cookie_our = Cookie(urandom(16))
     401     prv = gost3410.prv_unmarshal(urandom(32))
     402     pub_our = gost3410.public_key(CURVE, prv)
     403     pub_our_raw = PubKey(gost3410.pub_marshal(pub_our))
     404     writer.write(Msg(("handshake0", MsgHandshake0((
     405         ("cookieInitiator", cookie_our),
     406         ("pubKeyInitiator", pub_our_raw),
     407     )))).encode())
     408     # }}}
     409     await writer.drain()
    

    • ждём ответа и декодируем пришедшее Msg сообщение;
    • убеждаемся что получили handshake1;
    • декодируем эфемерный публичный ключ противоположной стороны и вычисляем сессионный ключ;
    • вырабатываем симметричные ключи необходимые для обработки TBE части сообщения.

     423     logging.info("%s: got %s message", _id, msg.choice)
     424     if msg.choice != "handshake1":
     425         logging.warning("%s: unexpected message, disconnecting", _id)
     426         writer.close()
     427         return
     428     # }}}
     429     msg_handshake1 = msg.value
     430     # Validate Handshake message {{{
     431     cookie_their = msg_handshake1["cookieResponder"]
     432     pub_their_raw = msg_handshake1["pubKeyResponder"]
     433     pub_their = gost3410.pub_unmarshal(bytes(pub_their_raw))
     434     ukm_raw = bytes(msg_handshake1["ukm"])
     435     ukm = ukm_unmarshal(ukm_raw)
     436     key_session = kek_34102012256(CURVE, prv, pub_their, ukm, mode=2001)
     437     kdf = Hkdf(None, key_session, hash=GOST34112012256)
     438     key_handshake1_mac_identity = kdf.expand(b"handshake1-mac-identity")
     439     key_handshake1_enc = kdf.expand(b"handshake1-enc")
     440     key_handshake1_mac = kdf.expand(b"handshake1-mac")
    

    UKM это 64-бит число (urandom(8)), которое тоже требует десериализации из байтового представления, используя gost3410_vko.ukm_unmarshal(). VKO функция для 34.10-2012 256-бит это gost3410_vko.kek_34102012256() (KEK — key encryption key).

    Выработанный сессионный ключ уже является 256-бит байтовой псевдослучайной последовательностью. Поэтому его сразу же можно использовать в HKDF функции. Так как GOST34112012256 удовлетворяет hashlib интерфейсу, то его можно сразу же использовать в Hkdf классе. Соль (первый аргумент Hkdf) мы не указываем, так как выработанный ключ из-за эфемерности участвующих ключевых пар будет разным для каждой сессии и в нём уже достаточно энтропии. kdf.expand() по умолчанию уже выдаёт ключи длиной 256-бит, требуемые для Кузнечика в дальнейшем.

    Далее проверяются TBE и TBS части пришедшего сообщения:

    • вычисляется и проверяется MAC над пришедшим шифротекстом;
    • дешифруется шифротекст;
    • декодируется TBE структура;
    • из неё берётся идентификатор собеседника и проверяется известен ли он нам вообще;
    • вычисляется и проверятся MAC над этим идентификатором;
    • проверяется подпись над TBS структурой, в которую входят cookie обеих сторон и публичный эфемерный ключ противоположной стороны. Подпись проверяется долгоживущим ключом подписи собеседника.

     441     try:
     442         peer_name = validate_tbe(
     443             msg_handshake1,
     444             key_handshake1_mac_identity,
     445             key_handshake1_enc,
     446             key_handshake1_mac,
     447             cookie_our,
     448             cookie_their,
     449             pub_their_raw,
     450         )
     451     except ValueError as err:
     452         logging.warning("%s: %s, disconnecting", _id, err)
     453         writer.close()
     454         return
     455     # }}}
    
     128 def validate_tbe(
     129         msg_handshake: Union[MsgHandshake1, MsgHandshake2],
     130         key_mac_identity: bytes,
     131         key_enc: bytes,
     132         key_mac: bytes,
     133         cookie_their: Cookie,
     134         cookie_our: Cookie,
     135         pub_key_our: PubKey,
     136 ) -> str:
     137     ciphertext = bytes(msg_handshake["ciphertext"])
     138     mac_tag = mac(GOST3412Kuznechik(key_mac).encrypt, KUZNECHIK_BLOCKSIZE, ciphertext)
     139     if not compare_digest(mac_tag, bytes(msg_handshake["ciphertextMac"])):
     140         raise ValueError("invalid MAC")
     141     plaintext = ctr(
     142         GOST3412Kuznechik(key_enc).encrypt,
     143         KUZNECHIK_BLOCKSIZE,
     144         ciphertext,
     145         8 * b"\x00",
     146     )
     147     try:
     148         tbe, _ = HandshakeTBE().decode(plaintext)
     149     except ASN1Error:
     150         raise ValueError("can not decode TBE")
     151     key_sign_pub_hash = bytes(tbe["identity"])
     152     peer = KEYS.get(key_sign_pub_hash)
     153     if peer is None:
     154         raise ValueError("unknown identity")
     155     mac_tag = mac(
     156         GOST3412Kuznechik(key_mac_identity).encrypt,
     157         KUZNECHIK_BLOCKSIZE,
     158         key_sign_pub_hash,
     159     )
     160     if not compare_digest(mac_tag, bytes(tbe["identityMac"])):
     161         raise ValueError("invalid identity MAC")
     162     tbs = HandshakeTBS((
     163         ("cookieTheir", cookie_their),
     164         ("cookieOur", cookie_our),
     165         ("pubKeyOur", pub_key_our),
     166     ))
     167     if not gost3410.verify(
     168         CURVE,
     169         peer["pub"],
     170         GOST34112012256(tbs.encode()).digest(),
     171         bytes(tbe["signature"]),
     172     ):
     173         raise ValueError("invalid signature")
     174     return peer["name"]
    

    Как уже писал выше, 34.13-2015 описывает различные режимы работы блочных шифров из 34.12-2015. Среди них есть режим выработки имитовставки, вычисления MAC-а. В PyGOST это gost3413.mac(). Этот режим требует передачи функции шифрования (принимающая и возвращающая один блок данных), размера шифроблока и, собственно, самих данных. Почему нельзя hardcode-ить размер шифроблока? 34.12-2015 описывает не только 128-битный шифр Кузнечик, но ещё и 64-битную Магму — немного изменённый ГОСТ 28147-89, созданный ещё в КГБ и до сих пор имеющий один из самых высоких порогов безопасности.

    Кузнечик инициализируется gost.3412.GOST3412Kuznechik(key) вызовом и возвращает объект с .encrypt()/.decrypt() методами, пригодными для передачи в 34.13 функции. MAC вычисляется следующим образом: gost3413.mac(GOST3412Kuznechik(key).encrypt, KUZNECHIK_BLOCKSIZE, ciphertext). Для сравнения вычисленного и пришедшего MAC-а нельзя использовать обычное сравнение (==) байтовых строк, так как это операция даёт утечки времени сравнения, что, в общем случае, может приводить к фатальным уязвимостям типа BEAST атаки на TLS. В Python имеется специальная hmac.compare_digest функция для этого.

    Функция блочного шифра может зашифровать только один блок данных. Для большего количества, да ещё и не кратной длины, необходимо использовать режим шифрования. В 34.13-2015 описаны следующие: ECB, CTR, OFB, CBC, CFB. У каждого свои допустимые сферы применения и характеристики. К огромному сожалению, у нас до сих пор нет стандартизованных аутентифицированных режимов шифрования (типа CCM, OCB, GCM и подобных) — мы вынуждены самостоятельно хотя бы добавлять MAC. Я выбираю режим счётчика (CTR): он не требует дополнения до размера блока, может распараллеливаться, использует только функцию шифрования, может быть безопасно использован для шифрования большого количества сообщений (в отличии от CBC, у которого относительно быстро начинаются коллизии).

    Как и .mac(), .ctr() принимает похожие данные на входе: ciphertext = gost3413.ctr(GOST3412Kuznechik(key).encrypt, KUZNECHIK_BLOCKSIZE, plaintext, iv). Требуется задание вектора инициализации, длиной ровно в половину шифроблока. Если наш ключ шифрования используется только для шифрования одного сообщения (пускай и из нескольких блоков), то безопасно задать нулевой вектор инициализации. Для шифрования handshake сообщений у нас используется каждый раз отдельный ключ.

    Проверка подписи gost3410.verify() тривиальна: передаём эллиптическую кривую в пределах которой работаем (её мы просто фиксируем в нашем GOSTIM протоколе), публичный ключ подписанта (не забываем, что это должен быть кортеж из двух больших чисел, а не байтовая строка), 34.11-2012 хэш и сама пришедшая подпись.

    Далее, в инициаторе мы подготавливаем и отсылаем handshake2 сообщение рукопожатия, производя те же самые действия что мы и делали при проверке, только симметрично: подпись на своих ключах вместо проверки, и т.п…

     456     # Prepare and send Handshake 2 message {{{
     457     tbs = HandshakeTBS((
     458         ("cookieTheir", cookie_their),
     459         ("cookieOur", cookie_our),
     460         ("pubKeyOur", pub_our_raw),
     461     ))
     462     signature = gost3410.sign(
     463         CURVE,
     464         KEY_OUR_SIGN_PRV,
     465         GOST34112012256(tbs.encode()).digest(),
     466     )
     467     key_handshake2_mac_identity = kdf.expand(b"handshake2-mac-identity")
     468     mac_tag = mac(
     469         GOST3412Kuznechik(key_handshake2_mac_identity).encrypt,
     470         KUZNECHIK_BLOCKSIZE,
     471         bytes(KEY_OUR_SIGN_PUB_HASH),
     472     )
     473     tbe = HandshakeTBE((
     474         ("identity", KEY_OUR_SIGN_PUB_HASH),
     475         ("signature", OctetString(signature)),
     476         ("identityMac", MAC(mac_tag)),
     477     ))
     478     tbe_raw = tbe.encode()
     479     key_handshake2_enc = kdf.expand(b"handshake2-enc")
     480     key_handshake2_mac = kdf.expand(b"handshake2-mac")
     481     ciphertext = ctr(
     482         GOST3412Kuznechik(key_handshake2_enc).encrypt,
     483         KUZNECHIK_BLOCKSIZE,
     484         tbe_raw,
     485         8 * b"\x00",
     486     )
     487     mac_tag = mac(
     488         GOST3412Kuznechik(key_handshake2_mac).encrypt,
     489         KUZNECHIK_BLOCKSIZE,
     490         ciphertext,
     491     )
     492     writer.write(Msg(("handshake2", MsgHandshake2((
     493         ("ciphertext", OctetString(ciphertext)),
     494         ("ciphertextMac", MAC(mac_tag)),
     495     )))).encode())
     496     # }}}
     497     await writer.drain()
     498     logging.info("%s: session established: %s", _id, peer_name)
     

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

     499     # Run text message sender, initialize transport decoder {{{
     500     key_initiator_enc = kdf.expand(b"transport-initiator-enc")
     501     key_initiator_mac = kdf.expand(b"transport-initiator-mac")
     502     key_responder_enc = kdf.expand(b"transport-responder-enc")
     503     key_responder_mac = kdf.expand(b"transport-responder-mac")
     ...
     509     asyncio.ensure_future(msg_sender(
     510         peer_name,
     511         key_initiator_enc,
     512         key_initiator_mac,
     513         writer,
     514     ))
     515     encrypter = GOST3412Kuznechik(key_responder_enc).encrypt
     516     macer = GOST3412Kuznechik(key_responder_mac).encrypt
     517     # }}}
     519     nonce_expected = 0
    
     520     # Wait for test messages {{{
     521     while True:
     522         data = await reader.read(MaxMsgLen)
     ...
     530             msg, tail = Msg().decode(buf)
     ...
     537         try:
     538             await msg_receiver(
     539                 msg.value,
     540                 nonce_expected,
     541                 macer,
     542                 encrypter,
     543                 peer_name,
     544             )
     545         except ValueError as err:
     546             logging.warning("%s: %s", err)
     547             break
     548         nonce_expected += 1
     549     # }}}
    

    msg_sender корутина теперь шифрует сообщения, перед отправкой в TCP-соединение. У каждого сообщения монотонно возрастающий nonce, также являющийся и вектором инициализации при шифровании в режиме счётчика. У каждого сообщения и блока сообщения гарантированно будут отличающиеся значения счётчика.

    async def msg_sender(peer_name: str, key_enc: bytes, key_mac: bytes, writer) -> None:
        nonce = 0
        encrypter = GOST3412Kuznechik(key_enc).encrypt
        macer = GOST3412Kuznechik(key_mac).encrypt
        in_queue = IN_QUEUES[peer_name]
        while True:
            text = await in_queue.get()
            if text is None:
                break
            ciphertext = ctr(
                encrypter,
                KUZNECHIK_BLOCKSIZE,
                text.encode("utf-8"),
                long2bytes(nonce, 8),
            )
            payload = MsgTextPayload((
                ("nonce", Integer(nonce)),
                ("ciphertext", OctetString(ciphertext)),
            ))
            mac_tag = mac(macer, KUZNECHIK_BLOCKSIZE, payload.encode())
            writer.write(Msg(("text", MsgText((
                ("payload", payload),
                ("payloadMac", MAC(mac_tag)),
            )))).encode())
            nonce += 1
    

    Приходящие сообщения обрабатываются корутиной msg_receiver, занимающейся аутентификацией и дешифрацией:

    async def msg_receiver(
            msg_text: MsgText,
            nonce_expected: int,
            macer,
            encrypter,
            peer_name: str,
    ) -> None:
        payload = msg_text["payload"]
        if int(payload["nonce"]) != nonce_expected:
            raise ValueError("unexpected nonce value")
        mac_tag = mac(macer, KUZNECHIK_BLOCKSIZE, payload.encode())
        if not compare_digest(mac_tag, bytes(msg_text["payloadMac"])):
            raise ValueError("invalid MAC")
        plaintext = ctr(
            encrypter,
            KUZNECHIK_BLOCKSIZE,
            bytes(payload["ciphertext"]),
            long2bytes(nonce_expected, 8),
        )
        text = plaintext.decode("utf-8")
        await OUT_QUEUES[peer_name].put(text)
    

    Заключение


    GOSTIM предполагается использовать исключительно в учебных целях (так как не покрыт тестами, как минимум)! Исходный код программы можно скачать тут (Стрибог-256 хэш: 995bbd368c04e50a481d138c5fa2e43ec7c89bc77743ba8dbabee1fde45de120). Как и все мои проекты, типа GoGOST, PyDERASN, NNCP, GoVPN, GOSTIM является полностью свободным ПО, распространяемым на условиях GPLv3+.

    Сергей Матвеев, шифропанк, член Фонда СПО, Python/Go-разработчик, главный специалист ФГУП «НТЦ „Атлас“.

Комментарии (7)


  1. humbug
    16.05.2019 19:17
    +1

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


    Можно это скрыть, если воспользоваться CurveCP или NK хэндшейком из NOISE.


    1. stargrave2 Автор
      16.05.2019 22:27

      Согласен со всем, но задачи скрытия факта общения и не ставилось даже в мыслях.


  1. worldmind
    17.05.2019 10:53

    Идея отличная, сам думаю, что когда-то надо взяться и написать, но видимо не скоро дойдут руки, нет пока времени.
    Думаю, что пока реальность такова, что белых айпишников у многих нет, но согласен, что это не повод городить костыли со всякими stun'ами, я думал что те у кого нет реального адреса должны размещать серверную часть на хостинге, это вполне допустимо т.к. там все сообщения зашифрованы. Ну и да, это платно, но в этом мире всё чего-то стоит, либо плати потерей приватности, либо лишайся заряда батареи — халявы нет.
    Дополнительно это упрощает создание мобильных клиентов — они не будут жрать батарею как это происходит когда мобила учаастник p2p сети.
    Тоже думал про friend-to-friend — добавление в контакты это обмен ключами по другим каналам.
    Но я думал сделать чуток фичастее — узнавать ip/port это чуток перебор (хотя ...) поэтому хотел p2p сеть с адресацией типа DHT делать или найти что-то готовое.


    1. stargrave2 Автор
      17.05.2019 11:05

      Tox IM я считаю хорошим вариантом для этого: DHT, современные NaCl алгоритмы, простой onion для приватности IP, итд. Но лично я не поклонник полностью децентрализованных сетей — много проблем, сложно обеспечить хороший QoS. Лично мне нравятся федеративные решения.


      1. worldmind
        17.05.2019 11:12

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


        1. stargrave2 Автор
          17.05.2019 11:38

          Я бы не назвал Tox большим и сложным и кода там тоже не шибко много. И не вижу большой проблемы что адрес привязан к узлу.


          1. worldmind
            17.05.2019 14:13

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

            дело личное, хотя странно, ведь был джаббер и эта проблема известна — исчез сервер и твой адрес потерян.