GOSTIM: P2P F2F E2EE IM за один вечер с ГОСТ-криптографией
Будучи разработчиком PyGOST библиотеки (ГОСТовые криптографические примитивы на чистом Python), я нередко получаю вопросы о том как на коленке реализовать простейший безопасный обмен сообщениями. Многие считают прикладную криптографию достаточно простой штукой, и .encrypt() вызова у блочного шифра будет достаточно для безопасной отсылки по каналу связи. Другие же считают, что прикладная криптография — удел немногих, и приемлемо, что богатые компании типа Telegram с олимпиадниками-математиками не могут реализовать безопасный протокол.
Всё это побудило меня написать данную статью, чтобы показать, что реализация криптографических протоколов и безопасного IM-а не такая сложная задача. Однако, изобретать собственные протоколы аутентификации и согласования ключей не стоит.
В статье будет написан 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 socket:
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: либо текстовое MsgText (пока с одним текстовым полем), либо сообщение рукопожатия MsgHandshake (в котором передаётся имя собеседника). Сейчас выглядит переусложнённым, но это задел на будущее.
Задаётся собственное имя (—our-name alice). Через запятую перечисляются все ожидаемые собеседники (—their-names bob,eve). Для каждого из собеседников, создаётся директория с Unix сокетами, а также по корутине на каждый in, out, state:
При чтении из 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 сообщение со своим именем:
Затем, ждёт ответа от удалённой стороны. Пытается декодировать пришедший ответ по Msg ASN.1 схеме. Предполагаем что всё сообщение будет отправлено одним TCP-сегментом и мы атомарно его получим при вызове .read(). Проверяем что мы получили именно handshake сообщение.
Проверяем что пришедшее имя собеседника нам известно. Если нет, то рвём соединение. Проверяем не было ли у нас уже установлено с ним соединение (собеседник вновь дал команду на подключение к нам) и закрываем его. В 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 и зеркально выполняет все те же самые действия, но бесконечный цикл чтения сообщений запускается сразу же, для простоты. Сейчас протокол рукопожатия отсылает по одному сообщению с каждой стороны, но, в дальнейшем, будет по два от инициатора соединения, после которых сразу же возможна отправка текстовых.
Пришло время обезопасить наше общение. Что же мы подразумеваем под безопасностью и что хотим:
конфиденциальность передаваемых сообщений;
аутентичность и целостность передаваемых сообщений — их изменение должно быть обнаружено;
защита от атак перепроигрывания (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. Самая базовая интересующая нас операция это согласование ключей: функция, на выходе которой оба участника получат одно и то же значение, которое можно будет использовать в качестве симметричного ключа. Не вдаваясь в подробности: каждая из сторон генерирует эфемерную (использующуюся только в пределах одной сессии) ключевую пару (публичный и приватный ключи), обмениваются публичными ключами, вызывают функцию согласования, на вход которой передают свой приватный ключ и публичный ключ собеседника.
Любой может встрять-по-середине и заменить публичные ключи своими собственными — в данном протоколе нет аутентификации собеседников. Добавим подпись долгоживущими ключами.
Такая подпись не подойдёт, так как она не привязана к конкретной сессии. Такие сообщения «подойдут» и для сессий с другими участниками. Подписываться должен весь контекст. Это вынуждает также добавить посылку ещё одного сообщения от A.
Кроме того, критично добавить под подпись и собственный идентификатор, так как, в противном случае, мы можем подменить IdXXX и переподписать сообщение ключом другого известным собеседника. Для предотвращения reflection атак, необходимо чтобы элементы под подписью находились в чётко заданных местах по своему смыслу: если A подписывает (PubA, PubB), то B должен подписывать (PubB, PubA). Это ещё и говорит о важности выбора структуры и формата сериализованных данных. Например, множества в ASN.1 DER кодировании сортируются: SET OF(PubA, PubB) будет идентичен SET OF(PubB, PubA).
Однако мы всё ещё не «доказали» что выработали одинаковый общий ключ для этой сессии. В принципе, можно обойтись и без этого шага — первое же транспортное сообщение будет невалидным, но мы хотим, чтобы когда рукопожатие завершилось, то были бы уверены что всё действительно согласовано. На данный момент у нас на руках ISO/IEC IS 9798-3 протокол.
Мы могли бы подписывать и сам выработанный ключ. Это опасно, так как не исключено, что в используемом алгоритме подписи могут быть утечки (пускай биты-на-подпись, но всё же утечки). Можно подписывать хэш от выработанного ключа, но утечка даже хэша от выработанного ключа может иметь ценность при brute-force атаке на функцию выработки. SIGMA использует MAC функцию, аутентифицирующую идентификатор отправителя.
В качестве оптимизации, некоторые могут захотеть переиспользовать свои эфемерные ключи (что, конечно, плачевно для PFS). Например, мы сгенерировали ключевую пару, попытались подключиться, но TCP не был доступен или оборвался где-то на середине протокола. Жалко тратить потраченную энтропию и ресурсы процессора на новую пару. Поэтому введём, так называемый, cookie — псевдослучайное значение, которое защитит от возможных случайных replay атак при повторном использовании эфемерных публичных ключей. Из-за binding-а между cookie и эфемерным публичным ключом, публичный ключ противоположного участника можно убрать из подписи за ненадобностью.
Наконец, мы хотим получить приватность наших идентификаторов собеседников от пассивного наблюдателя. Для этого SIGMA предлагает сначала обменяться эфемерными ключами, выработать общий ключ, на котором зашифровать аутентифицирующие и идентифицирующие сообщения. SIGMA описывает два варианта:
SIGMA-I — защищает инициатора от активных атак, ответчика от пассивных: инициатор аутентифицирует ответчика и если что-то не сошлось, то свою идентификацию он не выдаёт. Ответчик же выдаёт свою идентификацию если с ним начать активный протокол. Пассивный наблюдатель ничего не узнает;
SIGMA-R — защищает ответчика от активных атак, инициатора от пассивных. Всё с точностью до наоборот, но в этом протоколе уже четыре сообщения рукопожатия передаётся.
Выбираем SIGMA-I как более похожий на то, что мы ожидаем от клиент-серверных привычных вещей: клиента узнает только аутентифицированный сервер, а сервер и так знают все. Плюс он проще в реализации из-за меньшего количества сообщений рукопожатия. Всё что мы вносим в протокол, так это шифрование части сообщения и перенос идентификатора A в шифрованную часть последнего сообщения:
Для подписи используется ГОСТ Р 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. Выход нашей функции согласования ключей будет называться сессионным ключом, из которого будут вырабатываться недостающие симметричные:
HandshakeTBS — то, что будет подписываться (to be signed). HandshakeTBE — то, что будет зашифровано (to be encrypted). Обращаю внимание на поле ukm в MsgHandshake1. 34.10 VKO, для ещё большей рандомизации вырабатываемых ключей, включает параметр UKM (user keying material) — просто дополнительная энтропия.
Добавление криптографии в код
Рассмотрим лишь только внесённые к оригинальному коду изменения, так как каркас остался прежним (на самом деле, сначала была написана окончательная реализация, а потом из неё выпиливалась вся криптография).
Так как аутентификация и идентификация собеседников будет проводится по публичным ключам, то теперь их надо где-то долговременно хранить. Для простоты используем JSON такого вида:
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 функции согласования ключей.
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 обеих сторон и публичный эфемерный ключ противоположной стороны. Подпись проверяется долгоживущим ключом подписи собеседника.
Как уже писал выше, 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 сообщение рукопожатия, производя те же самые действия что мы и делали при проверке, только симметрично: подпись на своих ключах вместо проверки, и т.п…
Когда сессия установлена, то вырабатываются транспортные ключи (отдельный ключ для шифрования, для аутентификации, для каждой из сторон), инициализируется Кузнечик для дешифрования и проверки MAC-а:
msg_sender корутина теперь шифрует сообщения, перед отправкой в TCP-соединение. У каждого сообщения монотонно возрастающий nonce, также являющийся и вектором инициализации при шифровании в режиме счётчика. У каждого сообщения и блока сообщения гарантированно будут отличающиеся значения счётчика.
GOSTIM предполагается использовать исключительно в учебных целях (так как не покрыт тестами, как минимум)! Исходный код программы можно скачать тут (Стрибог-256 хэш: 995bbd368c04e50a481d138c5fa2e43ec7c89bc77743ba8dbabee1fde45de120). Как и все мои проекты, типа GoGOST, PyDERASN, NNCP, GoVPN, GOSTIM является полностью свободным ПО, распространяемым на условиях GPLv3+.