GOSTIM: P2P F2F E2EE IM w jeden wieczór z kryptografią GOST

Będąc programistą PyGOST biblioteki (kryptograficzne prymitywy zgodne z GOST w czystym Pythonie), często otrzymuję pytania, jak na szybko zrealizować najprostszy bezpieczny wymianę wiadomości. Wielu uważa, że kryptografia aplikacyjna jest dość prostą sprawą i wywołanie .encrypt() w przypadku szyfru blokowego wystarczy do bezpiecznego przesyłania przez kanał komunikacyjny. Inni z kolei sądzą, że kryptografia aplikacyjna to domena nielicznych i akceptowalne jest to, że bogate firmy takie jak Telegram z matematykami-olimpijczykami nie mogą zrealizować bezpiecznego protokołu.

To wszystko skłoniło mnie do napisania tego artykułu, aby pokazać, że realizacja protokołów kryptograficznych i bezpiecznego IM nie jest tak skomplikowanym zadaniem. Jednak nie warto wynajdować własnych protokołów uwierzytelniania i wymiany kluczy.

GOSTIM: P2P F2F E2EE IM w jeden wieczór z kryptografią GOST
W artykule zostanie opisany peer-to-peer, friend-to-friend, szyfrowany end-to-end instant messenger z PROTOKOLEM SIGMA-I uwierzytelniania i wymiany kluczy (na podstawie którego zaimplementowano IPsec IKE), używając wyłącznie kryptograficznych algorytmów GOST biblioteki PyGOST oraz kodowania ASN.1 przy pomocy biblioteki PyDERASN (o której już pisałem wcześniej). Warunkiem koniecznym jest, aby był na tyle prosty, aby można go było napisać od podstaw w jedną noc (lub dzień roboczy), w przeciwnym razie to już nie jest prosta aplikacja. Z pewnością zawiera błędy, nadmierne złożoności, niedociągnięcia, ponadto jest to moja pierwsza aplikacja z wykorzystaniem biblioteki asyncio.

Projekt IM

Na początek musimy zrozumieć, jak będzie wyglądać nasz IM. Dla uproszczenia, niech to będzie sieć peer-to-peer, bez wykrywania uczestników. Osobiście będziemy podawać adres: port, do którego będziemy się łączyć, aby rozmawiać z rozmówcą.

Rozumiem, że w tej chwili przypuszczenie o dostępności bezpośredniego połączenia między dwoma dowolnymi komputerami jest poważnym ograniczeniem w praktyce IM. Ale im więcej programistów zaimplementuje różne obejścia NAT-traversal, tym dłużej będziemy trwać w Internecie IPv4, z niepokojącą prawdopodobieństwem połączenia między dowolnymi komputerami. Ileż można znosić brak IPv6 w domu i w pracy?

Będziemy mieć sieć friend-to-friend: wszyscy możliwi rozmówcy muszą być wcześniej znani. Po pierwsze, to znacznie upraszcza: przedstawiliśmy się, znaleźliśmy lub nie znaleźliśmy imienia/klucza, rozłączyliśmy się lub kontynuujemy pracę, znając rozmówcę. Po drugie, w ogólnym przypadku jest to bezpieczne i wyklucza wiele ataków.

Interfejs IM będzie zbliżony do klasycznych rozwiązań suckless-projektów, które bardzo mi się podobają ze względu na minimalizm i filozofię Unix-way. Program IM dla każdego rozmówcy tworzy katalog z trzema gniazdami Unix domain socket:

  • in — zapisywane są w nim wysyłane rozmówcy wiadomości;
  • out — z niego czytane są wiadomości odebrane od rozmówcy;
  • state — czytając z niego, dowiadujemy się, czy rozmówca jest obecnie połączony, adres/port połączenia.

Ponadto tworzony jest gniazdo conn, zapisując w nim host i port, inicjujemy połączenie zdalnym rozmówcą.

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

Takie podejście pozwala na niezależne implementacje transportu IM i interfejsu użytkownika, bo jak mówi przysłowie, o gustach się nie dyskutuje, nie każdemu można dogodzić. Używając tmux i/lub multitail, można uzyskać wieloekranowy interfejs z podświetlaniem składni. A dzięki rlwrap , można uzyskać linię do wprowadzania wiadomości kompatybilną z GNU Readline.

W rzeczywistości, projekty suckless wykorzystują pliki FIFO. Osobiście nie mogłem zrozumieć, jak w asyncio pracować z plikami współbieżnie bez ręcznego podkładu z przydzielonych wątków (od dawna do takich rzeczy używam języka Go). Dlatego postanowiłem ograniczyć się do gniazd Unix domain. Niestety, to pozbawia możliwości wykonania echo 2001:470:dead::babe 6666 > conn. Rozwiązałem ten problem, używając socat: echo 2001:470:dead::babe 6666 | socat — UNIX-CONNECT:conn, socat READLINE UNIX-CONNECT:alice/in.

Pierwotny niebezpieczny protokół

Jako transport używany jest TCP: gwarantuje dostarczenie i jego porządek. UDP nie gwarantuje ani tego, ani tamtego (co byłoby przydatne, gdy zastosowana zostanie kryptografia), a wsparcia SCTP w Pythonie nie ma z pudełka.

Niestety, TCP nie ma pojęcia wiadomości, a jedynie strumienia bajtów. Dlatego musimy wymyślić format do wiadomości, aby można je było oddzielać w tym strumieniu. Na początek możemy ustalić użycie znaku nowej linii. Na razie wystarczy, jednak gdy zaczniemy szyfrować nasze wiadomości, ten znak może pojawić się gdziekolwiek w szyfrogramie. Dlatego w sieciach popularne są protokoły, które najpierw wysyłają długość wiadomości w bajtach. Na przykład w Pythonie na pokładzie jest xdrlib, która pozwala pracować z takim formatem. XDR.

Nie będziemy poprawnie i efektywnie pracować z czytaniem TCP — uprościmy kod. Odczytujemy dane z gniazda w nieskończonej pętli, aż odkodujemy pełną wiadomość. Możemy użyć JSON lub XML jako formatu dla takiego podejścia. Jednak gdy dodamy kryptografię, dane będą musiały być podpisane i uwierzytelnione — a to wymaga identycznej bajt-w-bajt reprezentacji obiektów, co nie jest zapewniane przez JSON/XML (wynik z dumps może się różnić).

XDR nadaje się do tego zadania, jednak wybieram ASN.1 z kodowaniem DER oraz PyDERASN bibliotekę, ponieważ będziemy mieć do czynienia z obiektami wysokiego poziomu, z którymi często przyjemniej i wygodniej się pracuje. W przeciwieństwie do schemaless bencode, MessagePack lub CBOR, ASN.1 automatycznie sprawdzi dane w odniesieniu do ściśle zdefiniowanej schemy.

# 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))),
    ))

Odebranym komunikatem będzie Msg: albo tekstowe MsgText (na razie z jednym polem tekstowym), albo komunikat ręcznego uścisku MsgHandshake (w którym przesyłane jest imię rozmówcy). Obecnie wydaje się to przesadnie skomplikowane, ale to inwestycja na przyszłość.

     ┌─────┐            ┌─────┐
     │PeerA│            │PeerB│
     └──┬──┘            └──┬──┘
        │MsgHandshake(IdA) │
        │─────────────────>│
        │                  │
        │MsgHandshake(IdB) │
        ││
        │                  │
        │    MsgText()     │
        │<─────────────────│
        │                  │

IM bez kryptografii

Jak już wspomniałem, do wszystkich operacji z gniazdami będzie używana biblioteka asyncio. Ogłosimy, czego oczekujemy w momencie uruchomienia:

parser = argparse.ArgumentParser(description="GOSTIM")
parser.add_argument(
    "--our-name",
    required=True,
    help="Nasza nazwa peer",
)
parser.add_argument(
    "--their-names",
    required=True,
    help="Ich nazwy peer, oddzielone przecinkami",
)
parser.add_argument(
    "--bind",
    default="::1",
    help="Adres, na którym nasłuchuje",
)
parser.add_argument(
    "--port",
    type=int,
    default=6666,
    help="Port, na którym nasłuchuje",
)
args = parser.parse_args()
OUR_NAME = UTF8String(args.our_name)
THEIR_NAMES = set(args.their_names.split(","))

Ustalana jest własna nazwa (—our-name alice). Wymieniane są wszystkie oczekiwane rozmówcy (—their-names bob,eve), oddzielone przecinkami. Dla każdego z rozmówców tworzony jest katalog z gniazdami Unix oraz coroutine dla każdego 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"))

Wiadomości przychodzące od użytkownika z gniazda in są wysyłane do kolejki 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"))

Wiadomości przychodzące od rozmówców są wysyłane do kolejek OUT_QUEUES, z których dane są zapisywane w gnieździe 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()

Podczas odczytu z gniazda state program szuka w słowniku PEER_ALIVE adresu rozmówcy. Jeśli połączenia z rozmówcą jeszcze nie ma, zostaje zapisana pusta linia.

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()

Podczas zapisu adresu w gnieździe conn uruchamiana jest funkcja „inicjatora” połączenia:

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))

Rozważmy inicjatora. Najpierw otwiera on, oczywiście, połączenie z podanym hostem/portem i wysyła wiadomość handshake ze swoją nazwą:

 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()

Następnie czeka na odpowiedź zdalnej strony. Próbuje zdekodować otrzymaną odpowiedź zgodnie z schematem Msg ASN.1. Zakładamy, że cała wiadomość zostanie wysłana w jednym segmencie TCP i otrzymamy ją atomowo podczas wywołania .read(). Sprawdzamy, czy otrzymaliśmy dokładnie wiadomość 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     # }}}

Sprawdzamy, czy przyjęta nazwa rozmówcy jest nam znana. Jeśli nie, to przerywamy połączenie. Sprawdzamy, czy już mieliśmy nawiązane połączenie z nim (rozmówca ponownie próbował się z nami połączyć) i zamykamy je. W kolejce IN_QUEUES umieszczane są ciągi Python z tekstem wiadomości, jednak istnieje specjalna wartość None, która sygnalizuje, by korutyna msg_sender zakończyła działanie, aby zapomniała o swoim writer związanym z przestarzałym połączeniem 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 przyjmuje wychodzące wiadomości (wrzucane do kolejki z gniazda in), serializuje je w wiadomości MsgText i wysyła przez połączenie TCP. Może ono przerwać się w dowolnym momencie — to chcemy wyraźnie przechwycić.

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))

Na końcu inicjator wchodzi w nieskończoną pętlę odczytu wiadomości z gniazda. Sprawdza, czy są to wiadomości tekstowe i umieszcza je w kolejce OUT_QUEUES, skąd będą wysyłane do gniazda out odpowiedniego rozmówcy. Dlaczego nie można po prostu robić .read() i dekodować wiadomość? Ponieważ nie można wykluczyć sytuacji, w której kilka wiadomości od użytkownika zostanie zgromadzonych w buforze systemu operacyjnego i wysłanych w jednym segmencie TCP. Dekodować będziemy mogli pierwszą, a w buforze może pozostać część z kolejnej. W przypadku jakiejkolwiek nieprzewidzianej sytuacji zamykamy połączenie TCP i zatrzymujemy korutynę msg_sender (wysyłając None do kolejki OUT_QUEUES).

 174     buf = b""
 175     # Czekaj na wiadomości testowe {{{
 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: przekroczono maksymalny rozmiar bufora", _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: nieoczekiwana wiadomość %s", _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: rozłączanie: %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: odebrano wiadomość o długości %d znaków", peer_name, len(text))
  69     await OUT_QUEUES[peer_name].put(text)

Wróćmy do głównego kodu. Po utworzeniu wszystkich korutyn w momencie uruchamiania programu uruchamiamy serwer TCP. Na każde nawiązane połączenie tworzy korutinę responder (odpowiadający).

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("Nasłuchiwanie na: %s", server.sockets[0].getsockname())
loop.run_forever()

responder jest podobny do initiator i wykonuje wszystkie te same działania, ale nieskończona pętla odczytu wiadomości uruchamia się natychmiast, dla uproszczenia. Obecnie protokół handshake wysyła po jednej wiadomości z każdej strony, ale w przyszłości będzie wysyłane po dwie od inicjatora połączenia, po których natychmiast możliwe będzie wysyłanie wiadomości tekstowych.

  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()

Bezpieczny protokół

Nadeszła pora, aby zabezpieczyć naszą komunikację. Co mamy na myśli przez bezpieczeństwo i czego się spodziewamy:

  • prywatność przesyłanych wiadomości;
  • autentyczność i integralność przesyłanych wiadomości — ich zmiana powinna być wykryta;
  • ochrona przed atakami powtórzeniowymi (replay attack) — fakt zniknięcia lub powtórzenia wiadomości powinien być wykryty (i decydujemy o zerwaniu połączenia);
  • identyfikacja i autoryzacja rozmówców na podstawie wcześniej ustalonych kluczy publicznych — wcześniej zdecydowaliśmy, że tworzymy sieć friend-to-friend. Tylko po autoryzacji dowiemy się, z kim rozmawiamy;
  • obecność doskonała tajemnica sesji (perfect forward secrecy) właściwości (PFS) — kompromitacja naszego długoterminowego klucza podpisu nie powinna prowadzić do możliwości odczytania całej wcześniejszej korespondencji. Nagranie przechwyconego ruchu staje się bezużyteczne;
  • ważność wiadomości (transportowych i handshake) tylko w obrębie jednej sesji TCP. Wstawienie poprawnie podpisanych/uwierzytelnionych wiadomości z innej sesji (nawet z tym samym rozmówcą) nie powinno być możliwe;
  • pasywny obserwator nie powinien widzieć ani identyfikatorów użytkowników, ani przekazywanych długoterminowych kluczy publicznych, ani ich skrótów. Pewna anonimowość od pasywnego obserwatora.

Zadziwiające, ale ten minimalny zestaw funkcji prawie wszyscy chcą mieć w każdym protokole handshake, a bardzo mało z wymienionych wymagań jest ostatecznie spełnionych przez „domowe” protokoły. Dlatego nie będziemy teraz wynajdować niczego nowego. Zdecydowanie poleciłbym użyć Noise framework do budowy protokołów, ale wybierzemy coś prostszego.

Najbardziej popularne są dwa protokoły:

  • TLS — najtrudniejszy protokół z długą historią błędów, niedociągnięć, luk, słabej przemyślności, złożoności i niedociągnięć (jednakże w niewielkim stopniu dotyczy to TLS 1.3). Nie rozważamy go z powodu nadmiernej złożoności.
  • IPsec z IKE — nie mają poważnych problemów kryptograficznych, chociaż też nie są proste. Jeśli poczytać o IKEv1 i IKEv2, ich źródłem są STS, ISO/IEC IS 9798-3 oraz protokoły SIGMA (SIGn-and-MAc) — wystarczająco proste do implementacji w jeden wieczór.

Czym SIGMA, jako ostatnie ogniwo rozwoju protokołów STS/ISO, jest dobre? Spełnia wszystkie nasze wymagania (w tym „ukrywania” identyfikatorów rozmówców), nie ma znanych problemów kryptograficznych. Jest minimalistyczny — usunięcie choćby jednego elementu z wiadomości protokołu spowoduje jego niebezpieczeństwo.

Przejdźmy od najprostszych domowych protokołów do SIGMA. Najbardziej podstawową interesującą nas operacją jest ustalanie kluczy: funkcja, na wyjściu której obaj uczestnicy uzyskają tę samą wartość, którą można będzie wykorzystać jako klucz symetryczny. Nie wnikając w szczegóły: każda ze stron generuje efemeryczną (używaną tylko w obrębie jednej sesji) parę kluczy (klucz publiczny i prywatny), wymieniają się kluczami publicznymi, wywołują funkcję ustalania, do której przekazują swój klucz prywatny i klucz publiczny rozmówcy.

┌─────┐          ┌─────┐
│PeerA│          │PeerB│
└──┬──┘          └──┬──┘
   │   IdA, PubA    │ ╔════════════════════╗
   │───────────────>│ ║PrvA, PubA = DHgen()║
   │                │ ╚════════════════════╝
   │   IdB, PubB    │ ╔════════════════════╗
   │<───────────────│ ║PrvB, PubB = DHgen()║
   │                │ ╚════════════════════╝
   ────┐    ╔═══════╧════════════╗
       │    ║Key = DH(PrvA, PubB)║
   <───┘    ╚═══════╤════════════╝
   │                │
   │                │

Każdy może wtrącić się i zamienić klucze publiczne na własne — w tym protokole nie ma uwierzytelnienia rozmówców. Dodajmy podpis przy użyciu długowiecznych kluczy.

┌─────┐                            ┌─────┐
│PeerA│                            │PeerB│
└──┬──┘                            └──┬──┘
   │IdA, PubA, sign(SignPrvA, (PubA)) │ ╔═══════════════════════════╗
   │─────────────────────────────────>│ ║SignPrvA, SignPubA = load()║
   │                                  │ ║PrvA, PubA = DHgen()       ║
   │                                  │ ╚═══════════════════════════╝
   │IdB, PubB, sign(SignPrvB, (PubB)) │ ╔═══════════════════════════╗
   │<─────────────────────────────────│ ║SignPrvB, SignPubB = load()║
   │                                  │ ║PrvB, PubB = DHgen()       ║
   │                                  │ ╚═══════════════════════════╝
   ────┐    ╔═════════════════════╗   │
       │    ║verify(SignPubB, ...)║   │
   <───┘    ║Key = DH(PrvA, PubB) ║   │
   │        ╚═════════════════════╝   │
   │                                  │

Taki podpis nie będzie odpowiedni, ponieważ nie jest powiązany z konkretną sesją. Tego typu wiadomości mogą być „odpowiednie” także dla sesji z innymi uczestnikami. Pełny kontekst powinien być podpisany. To zmusza nas również do dodania przesyłki jeszcze jednej wiadomości od A.

Ponadto krytyczne jest dodanie do podpisu własnego identyfikatora, ponieważ w przeciwnym razie możemy podmienić IdXXX i podpisać wiadomość kluczem innego znanego rozmówcy. W celu zapobieżenia atakom reflection, należy upewnić się, że elementy pod podpisem znajdują się w ściśle określonych miejscach zgodnie ze swoim znaczeniem: jeśli A podpisuje (PubA, PubB), to B musi podpisywać (PubB, PubA). To również mówi o znaczeniu wyboru struktury i formatu serializowanych danych. Na przykład zbiory w kodowaniu ASN.1 DER są sortowane: SET OF(PubA, PubB) będzie identyczny z SET OF(PubB, PubA).

┌─────┐                                       ┌─────┐
│PeerA│                                       │PeerB│
└──┬──┘                                       └──┬──┘
   │                 IdA, PubA                   │ ╔═══════════════════════════╗
   │────────────────────────────────────────────>│ ║SignPrvA, SignPubA = load()║
   │                                             │ ║PrvA, PubA = DHgen()       ║
   │                                             │ ╚═══════════════════════════╝
   │IdB, PubB, sign(SignPrvB, (IdB, PubA, PubB)) │ ╔═══════════════════════════╗
   │<────────────────────────────────────────────│ ║SignPrvB, SignPubB = load()║
   │                                             │ ║PrvB, PubB = DHgen()       ║
   │                                             │ ╚═══════════════════════════╝
   │     sign(SignPrvA, (IdA, PubB, PubA))       │ ╔═════════════════════╗
   │────────────────────────────────────────────>│ ║verify(SignPubB, ...)║
   │                                             │ ║Key = DH(PrvA, PubB) ║
   │                                             │ ╚═════════════════════╝
   │                                             │

Jednakże wciąż nie „udowodniliśmy”, że wypracowaliśmy taki sam wspólny klucz dla tej sesji. Zasadniczo można by się obejść bez tego kroku — pierwsza wiadomość transportowa byłaby nieważna, ale chcemy, aby po zakończeniu handshake'a mieć pewność, że wszystko zostało naprawdę uzgodnione. Obecnie posiadamy protokół ISO/IEC IS 9798-3.

Moglibyśmy podpisywać także sam wypracowany klucz. To niebezpieczne, ponieważ istnieje ryzyko, że w używanym algorytmie podpisu mogą wystąpić wycieki (nawet jeśli to bity-na-podpis, to jednak wycieki). Można podpisywać skrót wypracowanego klucza, ale wyciek nawet skrótu z wypracowanego klucza może mieć wartość w ataku brute-force na funkcję wytwarzania. SIGMA wykorzystuje funkcję MAC, która uwierzytelnia identyfikator nadawcy.

┌─────┐                                            ┌─────┐
│PeerA│                                            │PeerB│
└──┬──┘                                            └──┬──┘
   │                    IdA, PubA                     │ ╔═══════════════════════════╗
   │─────────────────────────────────────────────────>>│ ║SignPrvA, SignPubA = load()║
   │                                                  │ ║PrvA, PubA = DHgen()       ║
   │                                                  │ ╚═══════════════════════════╝
   │IdB, PubB, sign(SignPrvB, (PubA, PubB)), MAC(IdB) │ ╔═══════════════════════════╗
   │<─────────────────────────────────────────────────│ ║SignPrvB, SignPubB = load()║
   │                                                  │ ║PrvB, PubB = DHgen()       ║
   │                                                  │ ╚═══════════════════════════╝
   │                                                  │ ╔═════════════════════╗
   │     sign(SignPrvA, (PubB, PubA)), MAC(IdA)       │ ║Key = DH(PrvA, PubB) ║
   │─────────────────────────────────────────────────>>│ ║verify(Key, IdB)     ║
   │                                                  │ ║verify(SignPubB, ...)║
   │                                                  │ ╚═════════════════════╝
   │                                                  │

Jako optymalizację, niektórzy mogą chcieć ponownie wykorzystać swoje efemeryczne klucze (co, oczywiście, jest szkodliwe dla PFS). Na przykład, wygenerowaliśmy parę kluczy, próbowaliśmy się połączyć, ale TCP nie było dostępne lub połączenie zostało przerwane w trakcie protokołu. Szkoda marnować entropię i zasoby procesora na nową parę. Dlatego wprowadzimy tzw. ciastko — pseudolosową wartość, która zabezpieczy przed potencjalnymi atakami typu replay podczas ponownego użycia efemerycznych kluczy publicznych. Z powodu powiązania między ciastkiem a efemerycznym kluczem publicznym, klucz publiczny przeciwnej strony można usunąć z podpisu jako zbędny.

┌─────┐                                                                 ┌─────┐
│PeerA│                                                                 │PeerB│
└──┬──┘                                                                 └──┬──┘
   │                          IdA, PubA, CookieA                           │ ╔═══════════════════════════╗
   │──────────────────────────────────────────────────────────────────────>│ ║SignPrvA, SignPubA = load()║
   │                                                                       │ ║PrvA, PubA = DHgen()       ║
   │                                                                       │ ╚═══════════════════════════╝
   │IdB, PubB, CookieB, sign(SignPrvB, (CookieA, CookieB, PubB)), MAC(IdB) │ ╔═══════════════════════════╗
   ││ ║verify(Key, IdB)     ║
   │                                                                       │ ║verify(SignPubB, ...)║
   │                                                                       │ ╚═════════════════════╝
   │                                                                       │

Na koniec chcemy uzyskać prywatność naszych identyfikatorów rozmówców przed pasywnym obserwatorem. W tym celu SIGMA proponuje najpierw wymianę kluczy efemerycznych, opracowanie wspólnego klucza, na którym zaszyfrujemy autoryzacyjne i identyfikujące wiadomości. SIGMA opisuje dwa warianty:

  • SIGMA-I — chroni inicjatora przed aktywnymi atakami, a respondenta przed pasywnymi: inicjator autoryzuje respondenta i jeśli coś się nie zgadza, to nie ujawnia swojej identyfikacji. Respondent zaś ujawnia swoją identyfikację, jeśli rozpocznie aktywny protokół. Pasywny obserwator nic nie dowiaduje się;
    SIGMA-R — chroni respondenta przed aktywnymi atakami, a inicjatora przed pasywnymi. Wszystko jest dokładnie odwrotnie, ale w tym protokole przekazywanych jest już cztery wiadomości uzgadniających.

    Wybieramy SIGMA-I jako bardziej zbliżony do tego, czego oczekujemy od tradycyjnych rozwiązań klient-serwer: klient zna tylko uwierzytelniony serwer, a serwer i tak zna wszystko. Dodatkowo jest prostszy do wdrożenia z powodu mniejszej liczby komunikatów ręcznej wymiany. Wszystko, co wnosimy do protokołu, to szyfrowanie części komunikatu i przeniesienie identyfikatora A do szyfrowanej części ostatniego komunikatu:

    ┌─────┐                                                                        ┌─────┐
    │PeerA│                                                                        │PeerB│
    └──┬──┘                                                                        └──┬──┘
       │                                PubA, CookieA                                 │ ╔═══════════════════════════╗
       │─────────────────────────────────────────────────────────────────────────────►│ ║SignPrvA, SignPubA = load()║
       │                                                                              │ ║PrvA, PubA = DHgen()       ║
       │                                                                              │ ╚═══════════════════════════╝
       │PubB, CookieB, Enc((IdB, sign(SignPrvB, (CookieA, CookieB, PubB)), MAC(IdB))) │ ╔═══════════════════════════╗
       │◄─────────────────────────────────────────────────────────────────────────────│ ║SignPrvB, SignPubB = load()║
       │                                                                              │ ║PrvB, PubB = DHgen()       ║
       │                                                                              │ ╚═══════════════════════════╝
       │                                                                              │ ╔═════════════════════╗
       │       Enc((IdA, sign(SignPrvA, (CookieB, CookieA, PubA)), MAC(IdA)))         │ ║Key = DH(PrvA, PubB) ║
       │─────────────────────────────────────────────────────────────────────────────►│ ║verify(Key, IdB)     ║
       │                                                                              │ ║verify(SignPubB, ...)║
       │                                                                              │ ╚═════════════════════╝
       │                                                                              │
    
    • Do podpisywania używa się GOST R 34.10-2012 algorytmu z kluczami 256-bitowymi.
    • Do generowania wspólnego klucza używa się 34.10-2012 VKO.
    • Jako MAC stosuje się CMAC. Technicznie jest to szczególny tryb pracy algorytmu szyfrowania blokowego, opisany w GOST R 34.13-2015. Jako funkcję szyfrowania dla tego trybu — Kuznieczyk (34.12-2015).
    • Jako identyfikator rozmówcy używany jest skrót jego klucza publicznego. Skrótem jest Stribog-256 (34.11-2012 256 bitów).

    Po wykonaniu handshake'u ustalimy wspólny klucz. Możemy go używać do uwierzytelnionego szyfrowania komunikatów transportowych. Ta część jest dość prosta i trudno się w niej pomylić: inkrementujemy licznik wiadomości, szyfrujemy komunikat, uwierzytelniając (MAC) licznik i szyfrogram, wysyłamy. Podczas odbioru wiadomości sprawdzamy, czy licznik ma oczekiwaną wartość, uwierzytelniamy szyfrogram z licznikiem, deszyfrujemy. Jakim kluczem szyfrować wiadomości handshake, transportowe, oraz jak je uwierzytelniać? Używanie jednego klucza do wszystkich tych zadań jest niebezpieczne i nierozważne. Należy generować klucze, używając wyspecjalizowanych funkcji KDF (funkcja generacji kluczy). Ponownie, nie będziemy kombinować i wymyślać czegoś nowego: HKDF jest dobrze znana, dobrze zbadana i nie ma znanych problemów. Niestety, w rodzimej bibliotece Pythona nie ma tej funkcji, więc użyjemy hkdf pakietu. HKDF wewnętrznie używa HMAC, który z kolei korzysta z funkcji skrótu. Przykład implementacji w Pythonie na stronie Wikipedii zajmuje zaledwie kilka linii kodu. Podobnie jak w przypadku 34.10-2012, jako funkcję skrótu użyjemy Stribog-256. Wynik naszej funkcji generowania kluczy będzie nazywany kluczem sesyjnym, z którego będą generowane brakujące klucze symetryczne:

    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")
    

    Struktury/schematy

    Zobaczmy, jakie struktury ASN.1 otrzymaliśmy do przesyłania wszystkich tych danych:

    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, co będzie podpisywane (to be signed). HandshakeTBE – to, co będzie szyfrowane (to be encrypted). Zwracam uwagę na pole ukm w MsgHandshake1. 34.10 VKO, dla jeszcze większej losowości generowanych kluczy, obejmuje parametr UKM (user keying material) – po prostu dodatkowa entropia.

    Dodanie kryptografii do kodu

    Omówimy tylko wprowadzone zmiany do oryginalnego kodu, ponieważ struktura pozostała niezmieniona (tak naprawdę, najpierw stworzono ostateczną wersję, a potem usunięto całą kryptografię).

    Ponieważ autoryzacja i identyfikacja rozmówców będą przeprowadzane za pomocą kluczy publicznych, teraz muszą być one przechowywane gdzieś na stałe. Dla uproszczenia użyjemy JSON w takim formacie:

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

    our – nasza para kluczy, szesnastkowe klucze prywatne i publiczne. their – imiona rozmówców i ich klucze publiczne. Zmienimy argumenty wiersza poleceń i dodamy postprocessowanie danych 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="Generuj JSON z naszym nowym kluczem",
    )
    parser.add_argument(
        "--keys",
        default="keys.json",
        required=False,
        help="JSON z naszymi i ich kluczami",
    )
    parser.add_argument(
        "--bind",
        default="::1",
        help="Adres do nasłuchu",
    )
    parser.add_argument(
        "--port",
        type=int,
        default=6666,
        help="Port do nasłuchu",
    )
    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)
    
    # Parsowanie i unmarshalowanie naszych i ich kluczy {{{
    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),
        }
    # }}}
    

    Klucz prywatny algorytmu 34.10 to losowa liczba. Ma 256-bitów dla 256-bitowych krzywych eliptycznych. PyGOST działa nie na zestawie bajtów, a na dużych liczbach, dlatego nasz klucz prywatny (urandom(32)) musi być przekształcony w liczbę przy użyciu gost3410.prv_unmarshal(). Klucz publiczny jest wyznaczany deterministycznie z klucza prywatnego za pomocą gost3410.public_key(). Klucz publiczny 34.10 to dwie duże liczby, które również muszą być przekształcone w sekwencję bajtów dla łatwego przechowywania i przesyłania, używając gost3410.pub_marshal().

    Po odczytaniu pliku JSON, klucze publiczne muszą być odpowiednio przekształcone z powrotem, używając gost3410.pub_unmarshal(). Ponieważ identyfikatory rozmówców przychodzą w postaci skrótu klucza publicznego, mogą być od razu wyliczone i umieszczone w słowniku w celu szybkiego wyszukiwania. Skrót Stribog-256 to gost34112012256.GOST34112012256(), w pełni spełniający interfejs funkcji skrótu hashlib.

    Jak zmieniła się korutyna inicjatora? Wszystko, jak w schemacie rękoczynu: generujemy cookie (128-bit wystarczy), efemeryczny klucz pary 34.10, który będzie używany do funkcji VKO użycia klucza.

     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     # Generuj nasz efemeryczny klucz publiczny i ciasteczko, wysyłamy wiadomość Handshake 0 {{{
     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()
    

    • czekamy na odpowiedź i dekodujemy przychodzącą wiadomość Msg;
    • upewniamy się, że otrzymaliśmy handshake1;
    • dekodujemy efemeryczny klucz publiczny drugiej strony i obliczamy klucz sesyjny;
    • wypracowujemy klucze symetryczne potrzebne do przetwarzania części wiadomości 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     # Walidacja wiadomości Handshake {{{
     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 to 64-bit liczba (urandom(8)), która również wymaga deserializacji z bajtowego przedstawienia, używając gost3410_vko.ukm_unmarshal(). Funkcja VKO dla 34.10-2012 256-bit to gost3410_vko.kek_34102012256() (KEK — klucz szyfrowania klucza).

    Wypracowany klucz sesyjny jest już 256-bitową sekwencją bajtową o charakterze pseudolosowym. Dlatego można go od razu użyć w funkcji HKDF. Ponieważ GOST34112012256 spełnia interfejs hashlib, można go od razu użyć w klasie Hkdf. Sól (pierwszy argument Hkdf) nie jest podawana, ponieważ wypracowany klucz z powodu efemeryczności zaangażowanych par kluczy będzie różny dla każdej sesji i posiada wystarczającą entropię. kdf.expand() domyślnie już wydaje klucze o długości 256-bit, wymagane przez Kuczynka w dalszej części.

    Następnie sprawdzane są części TBE i TBS przychodzącej wiadomości:

    • obliczany jest i sprawdzany MAC dla przychodzącego tekstu szyfrowego;
    • deszyfrowany jest tekst szyfrowy;
    • dekodowana jest struktura TBE;
    • z niej wyciągany jest identyfikator rozmówcy i sprawdzane jest, czy jest nam znany;
    • obliczany jest i sprawdzany MAC dla tego identyfikatora;
    • podpis jest weryfikowana nad strukturą TBS, która zawiera ciasteczka obu stron oraz publiczny efemeryczny klucz drugiej strony. Podpis jest weryfikowany długoterminowym kluczem podpisu rozmówcy.

     441     spróbuj:
     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     wyjątek ValueError jako err:
     452         logging.warning("%s: %s, rozłączanie", _id, err)
     453         writer.close()
     454         zwróć
     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("nieprawidłowy MAC")
     141     plaintext = ctr(
     142         GOST3412Kuznechik(key_enc).encrypt,
     143         KUZNECHIK_BLOCKSIZE,
     144         ciphertext,
     145         8 * b"x00",
     146     )
     147     spróbuj:
     148         tbe, _ = HandshakeTBE().decode(plaintext)
     149     wyjątek ASN1Error:
     150         raise ValueError("nie można dekodować 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("nieznana tożsamość")
     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("nieprawidłowy MAC tożsamości")
     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("nieprawidłowy podpis")
     174     zwróć peer["name"]
    

    Jak już wspomniano powyżej, 34.13-2015 opisuje różne tryby pracy szyfrów blokowych z 34.12-2015. Wśród nich jest tryb generowania imityzacji, obliczania MAC-a. W PyGOST jest to gost3413.mac(). Ten tryb wymaga przekazania funkcji szyfrowania (przyjmującej i zwracającej jeden blok danych), rozmiaru bloku szyfrującego oraz danych. Dlaczego nie można ustawiać na sztywno rozmiaru bloku szyfrującego? 34.12-2015 opisuje nie tylko 128-bitowy szyfr Kuzniek, ale także 64-bitowy Magma — nieco zmodyfikowany GOST 28147-89, stworzony jeszcze w KGB i do dziś mający jeden z najwyższych progów bezpieczeństwa.

    Kuzniecik inicjalizuje gost.3412.GOST3412Kuznechik(key) za pomocą wywołania i zwraca obiekt z metodami .encrypt()/.decrypt(), które nadają się do przekazywania w funkcjach 34.13. MAC oblicza się w następujący sposób: gost3413.mac(GOST3412Kuznechik(key).encrypt, KUZNECHIK_BLOCKSIZE, ciphertext). Do porównania wygenerowanego i otrzymanego MAC-a nie można używać zwykłego porównania (==) łańcuchów bajtów, ponieważ ta operacja powoduje wycieki czasowe, co w ogólnym przypadku może prowadzić do krytycznych podatności typu BEAST ataków na TLS. W Pythonie istnieje specjalna funkcja hmac.compare_digest do tego celu.

    Funkcja szyfru blokowego może zaszyfrować tylko jeden blok danych. Aby obsłużyć więcej, a także o długości nie będącej wielokrotnością, należy użyć trybu szyfrowania. W 34.13-2015 opisane są następujące: ECB, CTR, OFB, CBC, CFB. Każdy z nich ma swoje akceptowalne obszary zastosowań i cechy. Niestety, wciąż nie mamy ustandaryzowanych autoryzowanych trybów szyfrowania (takich jak CCM, OCB, GCM i podobne) — jesteśmy zmuszeni przynajmniej do samodzielnego dodania MAC. Wybieram tryb licznika (CTR): nie wymaga uzupełnienia do rozmiaru bloku, może być równolegle przetwarzany, wykorzystuje tylko funkcję szyfrowania, może być bezpiecznie używany do szyfrowania dużej liczby wiadomości (w przeciwieństwie do CBC, w którym stosunkowo szybko zaczynają występować kolizje).

    Tak jak .mac(), .ctr() przyjmuje podobne dane jako wejście: ciphertext = gost3413.ctr(GOST3412Kuznechik(key).encrypt, KUZNECHIK_BLOCKSIZE, plaintext, iv). Wymagane jest określenie wektora inicjalizacji o długości dokładnie połowy bloku szyfrującego. Jeśli nasz klucz szyfrowania jest używany tylko do szyfrowania jednej wiadomości (nawet jeśli składa się z kilku bloków), można bezpiecznie ustawić zerowy wektor inicjalizacji. Do szyfrowania wiadomości handshake używamy za każdym razem osobnego klucza.

    Weryfikacja podpisu gost3410.verify() jest trywialna: przekazujemy krzywą eliptyczną, w obrębie której działamy (po prostu ją ustalamy w naszym protokole GOSTIM), publiczny klucz podpisującego (nie zapominajmy, że powinien to być krotka dwóch dużych liczb, a nie łańcuch bajtów), hasz 34.11-2012 i sam przychodzący podpis.

    Następnie w inicjatorze przygotowujemy i wysyłamy wiadomość handshake2, przeprowadzając te same czynności, co podczas weryfikacji, tylko symetrycznie: podpis na swoich kluczach zamiast weryfikacji, itd...

     456     # Przygotuj i wyślij wiadomość Handshake 2 {{{
     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: sesja nawiązana: %s", _id, peer_name)
     

    Gdy sesja zostanie nawiązana, generowane są klucze transportowe (osobny klucz do szyfrowania, do uwierzytelniania dla każdej ze stron), a Kucznik jest inicjalizowany do deszyfrowania i weryfikacji MAC-a:

     499     # Uruchom wysyłacza wiadomości tekstowych, zainicjalizuj dekoder transportowy {{{
     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     # Czekaj na wiadomości testowe {{{
     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     # }}}
    

    Coroutine msg_sender teraz szyfruje wiadomości przed wysłaniem ich w połączeniu TCP. Każda wiadomość ma monotonicznie rosnący nonce, który jest również wektorem inicjalizacji do szyfrowania w trybie licznika. Każda wiadomość i blok wiadomości gwarantują, że będą miały różne wartości licznika.

    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
    

    Przychodzące wiadomości są przetwarzane przez korutynę msg_receiver, która zajmuje się uwierzytelnianiem i deszyfrowaniem:

    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("nieoczekiwana wartość nonce")
        mac_tag = mac(macer, KUZNECHIK_BLOCKSIZE, payload.encode())
        if not compare_digest(mac_tag, bytes(msg_text["payloadMac"])):
            raise ValueError("nieprawidłowy 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)
    

    Podsumowanie

    GOSTIM jest przeznaczony wyłącznie do celów edukacyjnych (ponieważ nie ma pokrycia testami, przynajmniej)! Kod źródłowy programu można pobrać tutaj (hash Stryboga-256: 995bbd368c04e50a481d138c5fa2e43ec7c89bc77743ba8dbabee1fde45de120). Jak wszystkie moje projekty, typu GoGOST, PyDERASN, NNCP, GoVPN, GOSTIM jest całkowicie oprogramowaniem typu open source, rozpowszechnianym na warunkach GPLv3+.

    Siergiej Matwiejew, szifropank, członek Fundacji SPO, programista Python/Go, główny specjalista FGUP „NTC „Atlas”.

Źródło: habr.com

Kup solidny hosting stron z ochroną przed DDoS, serwery VPS VDS 🔥 Kup solidny hosting stron z ochroną przed DDoS, serwery VPS VDS | ProHoster