GOSTIM: P2P F2F E2EE IM in una sola serata con crittografia GOST

Essendo uno sviluppatore PyGOST libreria (primitivi crittografici GOST in puro Python), ricevo spesso domande su come implementare una semplice comunicazione sicura. Molti pensano che la crittografia applicativa sia piuttosto semplice, e che una chiamata a .encrypt() di un cifrario a blocchi sia sufficiente per un invio sicuro attraverso un canale di comunicazione. Altri, invece, credono che la crittografia applicativa sia riservata a pochi, e va bene che aziende ricche come Telegram, con matematici olimpionici non possano realizzare un protocollo sicuro.

Tutto ciò mi ha spinto a scrivere questo articolo, per dimostrare che l'implementazione di protocolli crittografici e di un IM sicuro non è un compito così complicato. Tuttavia, non vale la pena inventare protocolli di autenticazione e di scambio di chiavi propri.

GOSTIM: P2P F2F E2EE IM in una sola serata con crittografia GOST
Nell'articolo verrà descritto peer-to-peer, friend-to-friend, end-to-end crittografato instant messenger con SIGMA-I protocollo di autenticazione e scambio di chiavi (sulla base del quale è realizzato IPsec IKE), utilizzando esclusivamente algoritmi crittografici GOST della libreria PyGOST e codifica ASN.1 dei messaggi tramite la libreria PyDERASN (di cui ho già parlato in precedenza). Una condizione necessaria: deve essere così semplice da poter essere scritto da zero in una sola sera (o in una giornata lavorativa), altrimenti non è più un programma semplice. Probabilmente contiene errori, complessità superflue e imperfezioni, e inoltre è il mio primo programma utilizzando la libreria asyncio.

Design IM

Per cominciare, dobbiamo capire come apparirà il nostro IM. Per semplicità, consideriamo una rete peer-to-peer, senza alcuna rilevazione dei partecipanti. Indicheremo manualmente a quale indirizzo: porta connetterci per comunicare con l'interlocutore.

Capisco che, al momento, l'assunzione di una connessione diretta tra due computer qualsiasi rappresenti una limitazione significativa all'applicabilità dell'IM nella pratica. Ma più sviluppatori implementeranno vari escamotage per il NAT-traversal, più a lungo rimarremo nell'Internet IPv4, con la deprimente probabilità di connettersi tra computer casuali. Quanto a lungo possiamo tollerare l'assenza di IPv6 a casa e al lavoro?

Avremo una rete friend-to-friend: tutti i possibili interlocutori devono essere noti in anticipo. In primo luogo, questo semplifica notevolmente le cose: ci si presenta, si trova o non si trova il nome/chiave, ci si disconnette o si continua a lavorare, conoscendo l'interlocutore. In secondo luogo, in generale, è sicuro e esclude molteplici attacchi.

L'interfaccia dell'IM sarà simile a soluzioni classiche progetti suckless, che mi piacciono molto per il loro minimalismo e la filosofia Unix-way. Il programma IM crea una directory per ciascun interlocutore con tre socket di dominio Unix:

  • in — in essa vengono registrati i messaggi inviati all'interlocutore;
  • out — da essa vengono letti i messaggi ricevuti dall'interlocutore;
  • stato — leggendo da esso, possiamo sapere se il contatto è attualmente connesso, indirizzo/porta di connessione.

Inoltre, si crea un socket conn, nel quale memorizziamo l'host e la porta, per avviare la connessione con il contatto remoto.

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

Questo approccio consente di realizzare implementazioni indipendenti del trasporto IM e dell'interfaccia utente, perché non si può accontentare tutti i gusti. Utilizzando tmux e/o multitail, è possibile ottenere un'interfaccia multi-finestra con evidenziazione della sintassi. E con rlwrap si può ottenere una riga di input compatibile con GNU Readline per l'invio dei messaggi.

In realtà, i progetti suckless utilizzano file FIFO. Personalmente non sono riuscito a capire come lavorare con i file in asyncio in modo concorrente senza una base manuale di thread dedicati (per queste cose uso da tempo il linguaggio Go). Pertanto, ho deciso di utilizzare i socket Unix domain. Sfortunatamente, questo impedisce di eseguire echo 2001:470:dead::babe 6666 > conn. Ho risolto questo problema utilizzando socat: echo 2001:470:dead::babe 6666 | socat — UNIX-CONNECT:conn, socat READLINE UNIX-CONNECT:alice/in.

protocollo iniziale non sicuro

Il trasporto utilizza TCP: garantisce la consegna e il suo ordine. UDP non garantisce né l'uno né l'altro (cosa utile quando si applica la crittografia), mentre supporta SCTP non è disponibile di default in Python.

Sfortunatamente, in TCP non esiste il concetto di messaggio, solo di flusso di byte. Pertanto, è necessario inventare un formato per i messaggi in modo da poterli separare all'interno di questo flusso. Possiamo convenire di utilizzare il carattere di nuova riga. Per iniziare va bene, tuttavia, quando cominciamo a crittografare i nostri messaggi, questo carattere potrebbe apparire ovunque nel testo cifrato. Pertanto, nei network, sono popolari i protocolli che inviano prima la lunghezza del messaggio in byte. Ad esempio, in Python, è disponibile la libreria xdrlib che consente di lavorare con questo tipo di formato. XDR.

Non riusciremo a lavorare in modo corretto ed efficiente con la lettura TCP — semplifichiamo il codice. Leggiamo i dati dal socket in un ciclo infinito, finché non decodifichiamo il messaggio completo. Per un approccio del genere, possiamo utilizzare sia JSON che XML come formato. Tuttavia, quando si aggiunge la crittografia, i dati dovranno essere firmati e autenticati — il che richiederà una rappresentazione degli oggetti identica byte per byte, cosa che JSON/XML non garantisce (i risultati dei dump possono differire).

L'XDR è adatto per questo compito, tuttavia scelgo ASN.1 con codifica DER e PyDERASN una libreria, poiché avremo a disposizione oggetti di alto livello con cui è spesso più piacevole e conveniente lavorare. A differenza di schemaless bencode, MessagePack o CBOR, ASN.1 verificherà automaticamente i dati rispetto a uno schema rigido.

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

Il messaggio accettato sarà Msg: o un MsgText testuale (per ora con un solo campo di testo), oppure un messaggio di handshake MsgHandshake (in cui viene trasmesso il nome dell'interlocutore). Al momento appare troppo complicato, ma è un investimento per il futuro.

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

IM senza crittografia

Come ho già detto, per tutte le operazioni con i socket verrà utilizzata la libreria asyncio. Dichiareremo cosa ci aspettiamo al momento dell'avvio:

parser = argparse.ArgumentParser(description="GOSTIM")
parser.add_argument(
    "--our-name",
    required=True,
    help="Il nostro nome peer",
)
parser.add_argument(
    "--their-names",
    required=True,
    help="I loro nomi peer, separati da virgole",
)
parser.add_argument(
    "--bind",
    default="::1",
    help="Indirizzo su cui ascoltare",
)
parser.add_argument(
    "--port",
    type=int,
    default=6666,
    help="Porta su cui ascoltare",
)
args = parser.parse_args()
OUR_NAME = UTF8String(args.our_name)
THEIR_NAMES = set(args.their_names.split(","))

Si imposta il proprio nome (—our-name alice). Vengono elencati separati da virgola tutti i conversatori attesi (—their-names bob,eve). Per ciascuno dei conversatori, viene creata una directory con socket Unix e una coroutine per ogni in, out, stato:

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

I messaggi in arrivo dagli utenti dal socket in vengono inviati alle code 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"))

I messaggi in arrivo dai contatti vengono inviati alle code OUT_QUEUES, da cui i dati vengono scritti nel socket 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()

Quando si legge dal socket state, il programma cerca nell'indice PEER_ALIVE l'indirizzo del contatto. Se non c'è ancora una connessione con il contatto, viene scritto un stringa vuota.

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

Quando si registra un indirizzo nel socket conn, si attiva la funzione di "iniziatore" della connessione:

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

Esaminiamo l'iniziatore. Prima di tutto, apre, ovviamente, una connessione all'host/porta specificati e invia un messaggio di handshake con il proprio nome:

 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     # Messaggio di handshake {{{
 135     writer.write(Msg(("handshake", MsgHandshake((
 136         ("peerName", OUR_NAME),
 137     )))).encode())
 138     # }}}
 139     await writer.drain()

Poi, aspetta una risposta dalla parte remota. Cerca di decodificare la risposta ricevuta secondo lo schema Msg ASN.1. Presumiamo che l'intero messaggio venga inviato in un singolo segmento TCP e che lo riceveremo in modo atomico quando si chiama .read(). Verifichiamo di aver ricevuto esattamente il messaggio di handshake.

 141     # Aspettare il messaggio di handshake {{{
 142     data = await reader.read(256)
 143     if data == b"":
 144         logging.warning("%s: nessuna risposta, disconnettendo", _id)
 145         writer.close()
 146         return
 147     try:
 148         msg, _ = Msg().decode(data)
 149     except ASN1Error:
 150         logging.warning("%s: risposta non decodificabile, disconnettendo", _id)
 151         writer.close()
 152         return
 153     logging.info("%s: ricevuto messaggio %s", _id, msg.choice)
 154     if msg.choice != "handshake":
 155         logging.warning("%s: messaggio inaspettato, disconnettendo", _id)
 156         writer.close()
 157         return
 158     # }}}

Controlliamo che il nome del corrispondente ricevuto sia conosciuto. Se no, chiudiamo la connessione. Verifichiamo se avevamo già stabilito una connessione con lui (il corrispondente ha nuovamente inviato il comando di connessione) e la chiudiamo. Nella coda IN_QUEUES vengono inserite stringhe Python con il testo del messaggio, ma è presente un valore speciale None, che segnala alla coroutine msg_sender di interrompere il lavoro, affinché dimentichi il suo writer associato alla connessione TCP obsoleta.

 159     msg_handshake = msg.value
 160     peer_name = str(msg_handshake["peerName"])
 161     if peer_name not in THEIR_NAMES:
 162         logging.warning("nome peer sconosciuto: %s", peer_name)
 163         writer.close()
 164         return
 165     logging.info("%s: sessione stabilita: %s", _id, peer_name)
 166     # Esegui invio di messaggi di testo, inizializza il decoder di trasporto {{{
 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 gestisce i messaggi in uscita (immessi nella coda dal socket in), li serializza in un messaggio MsgText e li invia tramite connessione TCP. Questa connessione può interrompersi in qualsiasi momento — lo catturiamo esplicitamente.

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: inviato messaggio di %d caratteri", peer_name, len(text))

Alla fine, l'iniziatore entra in un ciclo infinito di lettura dei messaggi dal socket. Controlla se si tratta di messaggi di testo e li inserisce nella coda OUT_QUEUES, da cui saranno inviati al socket out del corrispondente interlocutore. Perché non possiamo semplicemente usare .read() e decodificare il messaggio? Perché non si può escludere che più messaggi dall'utente vengano aggregati nel buffer del sistema operativo e inviati in un unico segmento TCP. Possiamo decodificare solo il primo, mentre nel buffer potrebbe rimanere parte del successivo. In caso di qualsiasi situazione anomala, chiudiamo la connessione TCP e fermiamo la coroutine msg_sender (inviando None nella coda OUT_QUEUES).

 174     buf = b""
 175     # Aspetta i messaggi di test {{{
 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: dimensione massima del buffer superata", _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: messaggio %s inaspettato", _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: disconnessione: %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: messaggio ricevuto di %d caratteri", peer_name, len(text))
  69     await OUT_QUEUES[peer_name].put(text)

Torniamo al codice principale. Dopo aver creato tutte le coroutine al momento dell'avvio del programma, avviamo il server TCP. Per ogni connessione stabilita, crea una coroutine responder (risponditore).

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("In ascolto su: %s", server.sockets[0].getsockname())
loop.run_forever()

Il responder è simile all'initiator e compie esattamente le stesse azioni, ma il ciclo infinito di lettura dei messaggi viene avviato immediatamente, per semplicità. Attualmente, il protocollo di handshake invia un messaggio da ciascuna parte, ma in futuro ne saranno inviati due dall'initiator della connessione, dopo i quali sarà possibile inviare immediatamente testi.

  72 async def responder(reader, writer):
  73     _id = writer.get_extra_info("peername")
  74     logging.info("%s: connesso", _id)
  75     buf = b""
  76     msg_expected = "handshake"
  77     peer_name = None
  78     while True:
  79         # Leggi fino a ottenere il messaggio Msg {{{
  80         data = await reader.read(MaxMsgLen)
  81         if data == b"":
  82             logging.info("%s: connessione chiusa", _id)
  83             break
  84         buf += data
  85         if len(buf) > MaxMsgLen:
  86             logging.warning("%s: dimensione massima del buffer superata", _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: messaggio %s inatteso", _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         # Elabora il messaggio Handshake {{{
 104         elif msg_expected == "handshake":
 105             logging.info("%s: ricevuto messaggio %s", _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("nome peer sconosciuto: %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: sessione stabilita: %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: disconnessione", _id)
 125     if msg_expected == "text":
 126         IN_QUEUES[peer_name].put(None)
 127     writer.close()

Protocollo sicuro

È giunto il momento di garantire la sicurezza nella nostra comunicazione. Cosa intendiamo per sicurezza e cosa desideriamo:

  • riservatezza dei messaggi trasmessi;
  • autenticità e integrità dei messaggi trasmessi: eventuali modifiche devono essere rilevate;
  • protezione contro attacchi di riproduzione (replay attack): la perdita o la ripetizione dei messaggi devono essere rilevate (e decidiamo di interrompere la connessione);
  • identificazione e autenticazione dei comunicanti tramite chiavi pubbliche precedentemente impostate: abbiamo già deciso di creare una rete friend-to-friend. Solo dopo l'autenticazione sapremo con chi stiamo comunicando;
  • presenza perfect forward secrecy proprietà (PFS): la compromissione della nostra chiave di firma a lungo termine non deve comportare la possibilità di leggere tutta la corrispondenza precedente. La registrazione del traffico intercettato diventa inutile;
  • validità dei messaggi (di trasporto e di handshake) solo all'interno di una sessione TCP. L'inserimento di messaggi correttamente firmati/authenticati da una sessione diversa (anche con lo stesso interlocutore) non deve essere possibile;
  • un osservatore passivo non dovrebbe vedere né gli identificatori degli utenti, né le chiavi pubbliche a lungo termine trasmesse, né i loro hash. Una certa anonimato dall'osservatore passivo.

Incredibilmente, questa è una base che praticamente tutti desiderano avere in qualsiasi protocollo di handshake, e molto poco di quanto elencato alla fine viene effettivamente realizzato per i protocolli "fatti in casa". Anche adesso non inventiamo niente di nuovo. Consiglio vivamente di usare Noise framework per costruire protocolli, ma scegliamo qualcosa di più semplice.

I due protocolli più popolari sono:

  • TLS — un protocollo complesso con una lunga storia di bug, imperfezioni, vulnerabilità, scarsa progettazione, complessità e difetti (in effetti, ciò che riguarda TLS 1.3 non è molto rilevante). Ma non lo consideriamo a causa della sua eccessiva complessità.
  • IPsec con IKE — non presentano seri problemi crittografici, anche se non sono semplici. Se si legge di IKEv1 e IKEv2, le loro origini sono STS, standard ISO/IEC IS 9798-3 e i protocolli SIGMA (SIGn-and-MAc) — abbastanza semplici da implementare in una sola serata.

Cosa rende SIGMA, come ultima evoluzione dei protocolli STS/ISO, così vantaggioso? Soddisfa tutte le nostre esigenze (compresa la 'nascita' degli identificatori delle controparti), e non presenta problemi crittografici noti. È minimalista: l'eliminazione anche solo di un elemento dal messaggio del protocollo ne comprometterebbe la sicurezza.

Facciamo un viaggio dal protocollo più semplice a SIGMA. L'operazione di base che ci interessa è l'accordo dei chiavi: una funzione che consente ad entrambe le parti di ottenere lo stesso valore, utilizzabile come chiave simmetrica. Senza entrare troppo nei dettagli: ciascuna parte genera una coppia di chiavi effimere (utilizzate solo per una singola sessione), scambia chiavi pubbliche, e chiama la funzione di accordo, alla quale fornisce la propria chiave privata e la chiave pubblica dell'interlocutore.

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

Chiunque può intervenire e sostituire le chiavi pubbliche con le proprie: in questo protocollo non c'è autenticazione tra le parti. Aggiungiamo una firma con chiavi a lungo termine.

┌─────┐                            ┌─────┐
│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) ║   │
   │        ╚═════════════════════╝   │
   │                                  │

Questa firma non è valida poiché non è collegata a una sessione specifica. Messaggi di questo tipo possono essere utilizzati anche per sessioni con altri partecipanti. L'intero contesto deve essere firmato. Questo costringe a includere anche l'invio di un ulteriore messaggio da A.

Inoltre, è fondamentale aggiungere sotto la firma e un identificatore personale, altrimenti potremmo sostituire IdXXX e riscrivere il messaggio con la chiave di un altro interlocutore conosciuto. Per prevenire attacchi reflection, è necessario che gli elementi sotto la firma si trovino in posizioni chiaramente definite per il loro significato: se A firma (PubA, PubB), allora B deve firmare (PubB, PubA). Questo sottolinea anche l'importanza della scelta della struttura e del formato dei dati serializzati. Ad esempio, i set nell'encoding ASN.1 DER sono ordinati: SET OF(PubA, PubB) sarà identico a SET OF(PubB, PubA).

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

Tuttavia, non abbiamo ancora "dimostrato" di aver generato una chiave condivisa per questa sessione. In linea di principio, si può anche fare a meno di questo passaggio: il primo messaggio di trasporto sarà considerato non valido, ma vogliamo assicurarci che, al termine del handshake, tutto sia veramente concordato. Al momento, abbiamo in mano il protocollo ISO/IEC IS 9798-3.

Potremmo anche firmare la chiave generata. Questo è rischioso, poiché non è escluso che nell'algoritmo di firma utilizzato possano esserci delle perdite (anche se solo bit di firma, ma comunque perdite). È possibile firmare l'hash della chiave generata, ma una fuga anche solo dell'hash della chiave generata può avere valore durante attacchi brute-force sulla funzione di generazione. SIGMA utilizza una funzione MAC per autenticare l'identificatore del mittente.

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

Per ottimizzare, alcuni potrebbero voler riutilizzare le proprie chiavi effimere (cosa, ovviamente, dannosa per il PFS). Ad esempio, abbiamo generato una coppia di chiavi, abbiamo tentato di connetterci, ma il TCP non era disponibile o si è interrotto da qualche parte nel protocollo. È un peccato sprecare l'entropia e le risorse della CPU per una nuova coppia. Pertanto, introduciamo il cosiddetto cookie — un valore pseudocasuale che proteggerà da possibili attacchi di replay casuali durante il riutilizzo delle chiavi pubbliche effimere. A causa del binding tra il cookie e la chiave pubblica effimera, la chiave pubblica dell'altro partecipante può essere rimossa dalla firma per mancanza di necessità.

┌─────┐                                                                 ┌─────┐
│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, ...)║
   │                                                                       │ ╚═════════════════════╝
   │                                                                       │

Infine, vogliamo garantire la privacy dei nostri identificatori agli osservatori passivi. A tale scopo, SIGMA propone di scambiare prima chiavi effimere, generare una chiave condivisa per crittografare i messaggi di autenticazione e identificazione. SIGMA descrive due varianti:

  • SIGMA-I — protegge l'iniziatore dagli attacchi attivi e il rispondente da quelli passivi: l'iniziatore autentica il rispondente e, se qualcosa non corrisponde, non rivela la propria identificazione. Il rispondente, invece, fornisce la propria identificazione se viene avviato un protocollo attivo con lui. Un osservatore passivo non apprenderà nulla;
    SIGMA-R — protegge il rispondente dagli attacchi attivi e l'iniziatore da quelli passivi. Il tutto è esattamente inverso, ma in questo protocollo vengono trasmessi già quattro messaggi di handshake.

    Scegliamo SIGMA-I, poiché è più simile a ciò che ci aspettiamo dai normali aspetti client-server: il client riconosce solo il server autenticato, mentre il server conosce già tutto. Inoltre, è più semplice da implementare grazie a un numero ridotto di messaggi di handshake. L'unica cosa che introduciamo nel protocollo è la crittografia di una parte del messaggio e il trasferimento dell'identificatore A nella parte crittografata dell'ultimo messaggio:

    ┌─────┐                                                                        ┌─────┐
    │PeerA│                                                                        │PeerB│
    └──┬──┘                                                                        └──┬──┘
       │                                PubA, CookieA                                 │ ╔═══════════════════════════╗
       │─────────────────────────────────────────────────────────────────────────────>│ ║SignPrvA, SignPubA = load()║
       │                                                                              │ ║PrvA, PubA = DHgen()       ║
       │                                                                              │ ╚═══════════════════════════╝
       │PubB, CookieB, Enc((IdB, sign(SignPrvB, (CookieA, CookieB, PubB)), MAC(IdB))) │ ╔═══════════════════════════╗
       ││ ║verify(Key, IdB)     ║
       │                                                                              │ ║verify(SignPubB, ...)║
       │                                                                              │ ╚═════════════════════╝
       │                                                                              │
    
    • Per la firma viene utilizzato il GOST R 34.10-2012 algoritmo con chiavi a 256 bit.
    • Per la generazione della chiave condivisa viene utilizzato 34.10-2012 VKO.
    • Come MAC viene utilizzato CMAC. Questa è tecnicamente una modalità speciale di funzionamento di un cifrario a blocchi, descritta nel GOST R 34.13-2015. Come funzione di crittografia per questa modalità — Grillo (34.12-2015).
    • Come identificatore del partner viene utilizzata l'hash del suo chiave pubblica. Come hash viene applicato Stribog-256 (34.11-2012 256 bit).

    Dopo la stretta di mano avremo una chiave condivisa concordata. Questa può essere utilizzata per la crittografia autenticata dei messaggi trasportati. Questa parte è piuttosto semplice e difficile da sbagliare: incrementiamo il contatore dei messaggi, crittografiamo il messaggio, autentichiamo (MAC) il contatore e il testo crittografato, inviamo. Al ricevimento del messaggio verifichiamo che il contatore abbia il valore atteso, autentichiamo il testo crittografato con il contatore, e decrittografiamo. Quale chiave utilizzare per crittografare i messaggi di stretta di mano, i messaggi trasportati, quale per autenticare? Usare una sola chiave per tutte queste attività è pericoloso e poco saggio. È necessario generare chiavi utilizzando funzioni specializzate KDF (key derivation function). Non complicheremo le cose e non inveniamo nulla: HKDF è ben nota, ampiamente studiata e non presenta problemi noti. Sfortunatamente, la libreria standard di Python non include questa funzione, quindi utilizziamo hkdf come pacchetto. Internamente, HKDF utilizza HMAC, che a sua volta si basa su una funzione hash. Un esempio di implementazione in Python disponibile sulla pagina Wikipedia richiede poche righe di codice. Come nel caso di 34.10-2012, utilizzeremo la funzione hash Stribog-256. L'output della nostra funzione di derivazione della chiave sarà chiamato chiave di sessione, da cui saranno generate le simmetriche mancanti:

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

    Strutture/schemi

    Esaminiamo quali strutture ASN.1 abbiamo ora ottenuto per la trasmissione di tutti questi dati:

    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 — ciò che sarà firmato (to be signed). HandshakeTBE — ciò che sarà cifrato (to be encrypted). Si fa notare il campo ukm in MsgHandshake1. 34.10 VKO, per una maggiore casualità delle chiavi generate, include il parametro UKM (user keying material) — solo ulteriore entropia.

    Aggiunta di crittografia nel codice

    Considereremo solo le modifiche apportate al codice originale, poiché la struttura è rimasta la stessa (in realtà, prima è stata scritta l'implementazione finale, e poi è stata rimossa tutta la crittografia).

    Poiché l'autenticazione e l'identificazione dei partecipanti avverrà tramite le chiavi pubbliche, ora dobbiamo memorizzarle in modo duraturo. Per semplicità, utilizziamo un JSON di questo tipo:

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

    our — la nostra coppia di chiavi, chiavi esadecimali private e pubbliche. their — i nomi dei partecipanti e le loro chiavi pubbliche. Modifichiamo gli argomenti della riga di comando e aggiungiamo il post-trattamento dei dati 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="Genera JSON con la nostra nuova coppia di chiavi",
    )
    parser.add_argument(
        "--keys",
        default="keys.json",
        required=False,
        help="JSON con le nostre e le loro chiavi",
    )
    parser.add_argument(
        "--bind",
        default="::1",
        help="Indirizzo su cui ascoltare",
    )
    parser.add_argument(
        "--port",
        type=int,
        default=6666,
        help="Porta su cui ascoltare",
    )
    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)
    
    # Elenca e deserializza le nostre e le loro chiavi {{{
    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),
        }
    # }}}
    

    La chiave privata dell'algoritmo 34.10 è un numero casuale. Di dimensioni 256 bit per curve ellittiche a 256 bit. PyGOST non opera con un insieme di byte, ma con grandi numeri, quindi la nostra chiave privata (urandom(32)) deve essere convertita in un numero utilizzando gost3410.prv_unmarshal(). La chiave pubblica è calcolata in modo deterministico dalla chiave privata, usando gost3410.public_key(). La chiave pubblica 34.10 è composta da due numeri grandi, che devono anch'essi essere convertiti in una sequenza di byte per facilitarne la memorizzazione e la trasmissione, utilizzando gost3410.pub_marshal().

    Dopo aver letto il file JSON, le chiavi pubbliche devono essere convertite nuovamente, utilizzando gost3410.pub_unmarshal(). Poiché riceveremo gli identificatori dei contatti come hash della chiave pubblica, possiamo calcolarli in anticipo e inserirli in un dizionario per una ricerca rapida. L'hash Stribog-256 è gost34112012256.GOST34112012256(), che soddisfa completamente l'interfaccia delle funzioni hash di hashlib.

    Come è cambiata la coroutine dell'initiator? Tutto procede come nello schema del handshake: generiamo un cookie (128 bit sono più che sufficienti), una coppia di chiavi effimere 34.10, che sarà utilizzata per la funzione VKO di accordo delle chiavi.

     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     # Generiamo la nostra chiave pubblica effimera e cookie, inviamo il messaggio di 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()
    

    • Aspettiamo la risposta e decodifichiamo il messaggio Msg ricevuto;
    • Verifichiamo di aver ricevuto handshake1;
    • Decodifichiamo la chiave pubblica effimera dell'altra parte e calcoliamo la chiave di sessione;
    • Generiamo le chiavi simmetriche necessarie per gestire la parte TBE del messaggio.

     423     logging.info("%s: ricevuto il messaggio %s", _id, msg.choice)
     424     se msg.choice != "handshake1":
     425         logging.warning("%s: messaggio inaspettato, disconnessione", _id)
     426         writer.close()
     427         return
     428     # }}}
     429     msg_handshake1 = msg.value
     430     # Convalida il messaggio 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 è un numero a 64 bit (urandom(8)), che richiede anche la deserializzazione dalla rappresentazione byte, utilizzando gost3410_vko.ukm_unmarshal(). La funzione VKO per 34.10-2012 a 256 bit è gost3410_vko.kek_34102012256() (KEK — key encryption key).

    La chiave di sessione generata è già una sequenza pseudo-casuale di 256 bit. Pertanto, può essere immediatamente utilizzata nella funzione HKDF. Poiché GOST34112012256 soddisfa l'interfaccia hashlib, può essere utilizzato immediatamente nella classe Hkdf. Non specificiamo il sale (il primo argomento di Hkdf) poiché la chiave generata, a causa della transitorietà delle coppie di chiavi coinvolte, sarà diversa per ogni sessione e conterrà già sufficiente entropia. kdf.expand() restituisce già per impostazione predefinita chiavi lunghe 256 bit, necessarie per il Kuznets.

    Successivamente, vengono controllate le parti TBE e TBS del messaggio ricevuto:

    • si calcola e verifica il MAC sul testo cifrato ricevuto;
    • si decritta il testo cifrato;
    • si decodifica la struttura TBE;
    • da essa viene estratto l'identificatore dell'interlocutore e si verifica se ci è noto;
    • si calcola e verifica il MAC su questo identificatore;
    • si verifica la firma sulla struttura TBS, che include i cookie di entrambe le parti e la chiave pubblica effimera dell'altra parte. La firma viene verificata con la chiave di firma a lungo termine dell'interlocutore.

     441     prova:
     442         nome_peer = 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     eccetto ValueError come err:
     452         logging.warning("%s: %s, disconnesso", _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     se non compare_digest(mac_tag, bytes(msg_handshake["ciphertextMac"])):
     140         raise ValueError("MAC non valido")
     141     plaintext = ctr(
     142         GOST3412Kuznechik(key_enc).encrypt,
     143         KUZNECHIK_BLOCKSIZE,
     144         ciphertext,
     145         8 * b"x00",
     146     )
     147     prova:
     148         tbe, _ = HandshakeTBE().decode(plaintext)
     149     eccetto ASN1Error:
     150         raise ValueError("impossibile decodificare TBE")
     151     key_sign_pub_hash = bytes(tbe["identity"])
     152     peer = KEYS.get(key_sign_pub_hash)
     153     se peer è None:
     154         raise ValueError("identità sconosciuta")
     155     mac_tag = mac(
     156         GOST3412Kuznechik(key_mac_identity).encrypt,
     157         KUZNECHIK_BLOCKSIZE,
     158         key_sign_pub_hash,
     159     )
     160     se non compare_digest(mac_tag, bytes(tbe["identityMac"])):
     161         raise ValueError("MAC identità non valido")
     162     tbs = HandshakeTBS((
     163         ("cookieTheir", cookie_their),
     164         ("cookieOur", cookie_our),
     165         ("pubKeyOur", pub_key_our),
     166     ))
     167     se non gost3410.verify(
     168         CURVE,
     169         peer["pub"],
     170         GOST34112012256(tbs.encode()).digest(),
     171         bytes(tbe["signature"]),
     172     ):
     173         raise ValueError("firma non valida")
     174     return peer["name"]
    

    Come già accennato in precedenza, la 34.13-2015 descrive diversi modi di funzionamento dei cifrari a blocchi dalla 34.12-2015. Tra di essi c'è la modalità di generazione della firma, calcolo del MAC. In PyGOST si usa gost3413.mac(). Questa modalità richiede la trasmissione della funzione di crittografia (che accetta e restituisce un blocco di dati), della dimensione del blocco di crittografia e, naturalmente, dei dati stessi. Perché non si può hardcodare la dimensione del blocco di crittografia? La 34.12-2015 descrive non solo il cifrario a 128 bit Kuznechik, ma anche il 64 bit Magma – una versione leggermente modificata del GOST 28147-89, creata ancora al KGB e tuttora dotata di uno dei più alti livelli di sicurezza.

    Kuznechik si inizializza con la chiamata gost.3412.GOST3412Kuznechik(key) e restituisce un oggetto con metodi .encrypt()/.decrypt() idonei per essere passati alla funzione 34.13. Il MAC si calcola nel seguente modo: gost3413.mac(GOST3412Kuznechik(key).encrypt, KUZNECHIK_BLOCKSIZE, ciphertext). Per confrontare il MAC calcolato e quello ricevuto non si può usare il normale confronto (==) delle stringhe di byte, poiché questa operazione potrebbe generare perdite di tempo di confronto, che, in generale, possono portare a vulnerabilità fatali come BEAST gli attacchi TLS. In Python esiste una funzione specifica hmac.compare_digest per questo.

    La funzione di cifratura a blocchi può crittografare solo un singolo blocco di dati. Per una maggiore quantità, e non di lunghezza multipla, è necessario utilizzare una modalità di crittografia. Nella norma 34.13-2015 sono descritti i seguenti: ECB, CTR, OFB, CBC, CFB. Ognuno ha i propri ambiti e caratteristiche d'uso. Purtroppo, non abbiamo ancora modalità di crittografia standardizzate autenticate (come CCM, OCB, GCM e simili) — siamo costretti almeno a implementare noi un MAC. Scelgo la modalità contatore (CTR): non richiede un'integrazione alla dimensione del blocco, può essere parallelizzata, utilizza solo la funzione di crittografia, può essere usata in sicurezza per crittografare un gran numero di messaggi (a differenza del CBC, che inizia a presentare collisioni relativamente rapidamente).

    Come .mac(), anche .ctr() accetta dati simili in input: ciphertext = gost3413.ctr(GOST3412Kuznechik(key).encrypt, KUZNECHIK_BLOCKSIZE, plaintext, iv). È necessario impostare un vettore di inizializzazione, lungo esattamente la metà della dimensione del blocco di cifratura. Se la nostra chiave di cifratura viene utilizzata solo per cifrare un singolo messaggio (anche se composto da più blocchi), è sicuro specificare un vettore di inizializzazione nullo. Per cifrare i messaggi handshake utilizziamo di volta in volta una chiave separata.

    Il controllo della firma gost3410.verify() è triviale: dobbiamo fornire la curva ellittica entro la quale operiamo (che fissiamo nel nostro protocollo GOSTIM), la chiave pubblica del firmatario (ricordando che deve essere una coppia di numeri grandi, non una stringa di byte), l'hash 34.11-2012 e la firma ricevuta.

    Successivamente, nell'iniziatore prepariamo e inviamo il messaggio handshake2 di handshake, eseguendo le stesse operazioni fatte durante il controllo, ma simmetricamente: firmiamo con le nostre chiavi invece di controllare, e così via...

     456     # Prepara e invia il messaggio 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: session established: %s", _id, peer_name)
     

    Una volta stabilita la sessione, vengono generati i chiavi di trasporto (una per la crittografia, una per l'autenticazione, per ciascuna delle parti), e viene inizializzato Kuznechik per la decrittazione e la verifica del MAC:

     499     # Esegui il mittente di messaggi di testo, inizializza il decodificatore di trasporto {{{
     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     # Attendi i messaggi di prova {{{
     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     # }}}
    

    La coroutine msg_sender ora crittografa i messaggi prima di inviarli nella connessione TCP. Ogni messaggio ha un nonce che cresce in modo monotono, che è anche un vettore di inizializzazione quando si crittografa in modalità contatore. Ogni messaggio e blocco di messaggi garantirà valori del contatore distinti.

    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
    

    I messaggi in arrivo vengono elaborati dalla coroutine msg_receiver, che si occupa dell'autenticazione e della decrittazione:

    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("valore nonce inatteso")
        mac_tag = mac(macer, KUZNECHIK_BLOCKSIZE, payload.encode())
        if not compare_digest(mac_tag, bytes(msg_text["payloadMac"])):
            raise ValueError("MAC non valido")
        plaintext = ctr(
            encrypter,
            KUZNECHIK_BLOCKSIZE,
            bytes(payload["ciphertext"]),
            long2bytes(nonce_expected, 8),
        )
        text = plaintext.decode("utf-8")
        await OUT_QUEUES[peer_name].put(text
    

    Conclusione

    GOSTIM è previsto esclusivamente per scopi didattici (poiché non coperto da test, almeno)! Il codice sorgente del programma può essere scaricato qui (Stribog-256 hash: 995bbd368c04e50a481d138c5fa2e43ec7c89bc77743ba8dbabee1fde45de120). Come tutti i miei progetti, di tipo GoGOST, PyDERASN, NNCP, GoVPN, GOSTIM è completamente software libero, distribuito sotto i termini GPLv3+.

    Sergey Matveev, cypherpunk, membro del Fondo SPo, sviluppatore Python/Go, esperto principale FGUP «NTC „Atlas“.

Fonte: habr.com

Acquista hosting affidabile per siti web con protezione DDoS, VPS VDS server 🔥 Acquista hosting affidabile per siti web con protezione DDoS, VPS VDS server | ProHoster