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

Essendo uno sviluppatore PyGOST di una libreria (primitivi crittografici GOST in puro Python), ricevo spesso domande su come implementare un semplice scambio di messaggi sicuro. Molti ritengono che la crittografia applicata sia abbastanza semplice e che una chiamata a .encrypt() a un cifratore a blocchi sia sufficiente per una comunicazione sicura. Altri, invece, credono che la crittografia applicata sia appannaggio di pochi e che sia accettabile che aziende ricche come Telegram con matematici olimpici non possano implementare un protocollo sicuro.

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

GOSTIM: P2P F2F E2EE IM in una sola sera con la crittografia GOST
Nell'articolo verrà scritto peer-to-peer, friend-to-friend, un messaggero istantaneo crittografato end-to-end con il protocollo SIGMA-I per l'autenticazione e lo scambio delle chiavi (su cui è basato IPsec IKE ), utilizzando esclusivamente gli algoritmi crittografici GOST della libreria PyGOST e la codifica dei messaggi con la libreriaPyDERASN (di cui ho già scritto in precedenza ). Condizione necessaria: deve essere così semplice da poter essere scritto da zero in una sola serata (o giornata lavorativa), altrimenti non è più un programma semplice. Sicuramente ci sono errori, complicazioni superflue, imprecisioni, e inoltre è il mio primo programma con l'uso della libreria asyncio.Design dell'IM

Per cominciare, dobbiamo capire come sarà il nostro IM. Per semplicità, supponiamo che sia una rete peer-to-peer, senza alcuna rilevazione dei partecipanti. Indicheremo manualmente a quale indirizzo: porto collegarci per comunicare con il nostro interlocutore.

Capisco che, al momento, l'ipotesi sulla disponibilità di una connessione diretta tra due computer arbitrari rappresenta una limitazione significativa nell'applicabilità dell'IM nella pratica. Ma più sviluppatori implementeranno costrizioni di traversamento NAT, più a lungo rimarremo nella rete IPv4, con la deprimente probabilità di comunicazione tra computer arbitrari. Quanto ancora dobbiamo sopportare l'assenza di IPv6 a casa e al lavoro?

Capisco che, al momento, l'idea della disponibilità di una connessione diretta tra due computer qualsiasi rappresenti una significativa limitazione all'applicabilità di IM nella pratica. Ma più sviluppatori implementeranno vari stratagemmi di NAT-traversal, più a lungo continueremo a rimanere nell'Internet IPv4, con la deprimente probabilità di connessione tra computer qualsiasi. Quanto ancora dobbiamo sopportare 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 tutto: ci presentiamo, troviamo o non troviamo il nome/chiave, ci scollegiamo o continuiamo a lavorare, conoscendo l'interlocutore. In secondo luogo, in generale, questo è sicuro ed esclude molteplici attacchi.

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

  • in — in cui vengono scritti i messaggi inviati all'interlocutore;
  • out — da cui vengono letti i messaggi ricevuti dall'interlocutore;
  • state — leggendo da qui, scopriamo se l'interlocutore è attualmente connesso, indirizzo/porta di connessione.

Inoltre, viene creato un socket conn, scrivendo in cui l'host porta, iniziamo la connessione con l'interlocutore remoto.

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

Questo approccio consente di realizzare implementazioni indipendenti del trasporto IM e dell'interfaccia utente, poiché a ciascuno il proprio gusto e colore, non si può accontentare tutti. Usando tmux e/o multitail, è possibile ottenere un'interfaccia multi-finestra con evidenziazione della sintassi. E con l'aiuto di rlwrap , è possibile 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 un supporto manuale di thread dedicati (per tali cose utilizzo da tempo il linguaggio Go). Perciò ho deciso di utilizzare i socket di dominio Unix. Purtroppo, questo elimina la possibilità di fare echo 2001:470:dead::babe 6666 > conn. Ho risolto questo problema, usando socat: echo 2001:470:dead::babe 6666 | socat — UNIX-CONNECT:conn, socat READLINE UNIX-CONNECT:alice/in.

Protocollo iniziale non sicuro

Come trasporto viene utilizzato TCP: garantisce la consegna e il suo ordine. UDP non garantisce né l'uno né l'altro (cosa che sarebbe utile quando si applica la crittografia), e il supporto per SCTP in Python non è incluso di default.

Purtroppo, in TCP non esiste il concetto di messaggio, ma solo di flusso di byte. Pertanto, è necessario ideare un formato per i messaggi, in modo da poterli separare l'uno dall'altro in questo flusso. Possiamo convenire di utilizzare il carattere di ritorno a capo. Per iniziare va bene, tuttavia, quando iniziamo a crittografare i nostri messaggi, questo simbolo potrebbe apparire ovunque nel testo cifrato. Nei network, per questo motivo, i protocolli popolari inviano prima la lunghezza del messaggio in byte. Ad esempio, in Python esiste in bundle xdrlib che consente di lavorare con un formato simile. XDR.

Non lavoreremo in modo corretto ed efficiente con la lettura TCP — semplificheremo il codice. Leggiamo in un ciclo infinito i dati dal socket, finché non decodifichiamo il messaggio completo. Come formato per questo approccio possiamo usare anche JSON e XML. Ma, quando verrà aggiunta la crittografia, i dati dovranno essere firmati e autenticati — e questo richiederà una rappresentazione byte-per-byte identica degli oggetti, cosa che JSON/XML non garantiscono (il risultato dei dumps può differire).

XDR è adatto per questo compito, tuttavia scelgo ASN.1 con codifica DER e (di cui ho già una libreria, poiché avremo oggetti di alto livello con cui spesso è più gradevole e conveniente lavorare. A differenza di schemi senza schema bencode, MessagePack o CBOR, ASN.1 controllerà automaticamente i dati rispetto a uno schema rigorosamente definito.

# 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 testuale), o un messaggio di handshake MsgHandshake (in cui viene trasmesso il nome dell’interlocutore). Adesso sembra troppo complicato, ma è una preparazione 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 di peer",
)
parser.add_argument(
    "--their-names",
    required=True,
    help="I loro nomi di 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(","))

Viene specificato un nome personale (—our-name alice). I nomi di tutti i peer attesi vengono elencati separati da virgole (—their-names bob,eve). Per ciascun peer, viene creata una directory con i socket Unix, oltre a una coroutine per ogni 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"))

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

Durante la lettura dal socket di stato, il programma cerca nell'archivio PEER_ALIVE l'indirizzo del peer. Se non ci sono ancora connessioni con il peer, viene registrata una 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 scrive l'indirizzo nel socket conn, viene avviata la funzione "initiator" 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))

Consideriamo l'initiator. Inizialmente, ovviamente, apre una connessione all'host/porta specificato e invia un messaggio di handshake con il suo nome:

 130 async def initiator(host, port):
 131     _id = repr((host, port))
 132     logging.info("%s: composizione", _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()

Dopo, aspetta una risposta dalla parte remota. Cerca di decodificare la risposta ricevuta secondo lo schema Msg ASN.1. Supponiamo che l'intero messaggio sia inviato in un unico segmento TCP e che lo riceveremo in modo atomico con la chiamata a .read(). Controlliamo se abbiamo effettivamente ricevuto il messaggio di handshake.

 141     # Aspetta il messaggio di handshake {{{
 142     data = await reader.read(256)
 143     if data == b"":
 144         logging.warning("%s: nessuna risposta, disconnessione", _id)
 145         writer.close()
 146         return
 147     try:
 148         msg, _ = Msg().decode(data)
 149     except ASN1Error:
 150         logging.warning("%s: risposta illeggibile, disconnessione", _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 inatteso, disconnessione", _id)
 156         writer.close()
 157         return
 158     # }}}

Controlliamo se il nome del peer ricevuto è a noi noto. Se non lo è, interrompiamo la connessione. Controlliamo se avevamo già stabilito una connessione con lui (il peer ha di nuovo richiesto di connettersi a noi) e la chiudiamo. Nella coda IN_QUEUES vengono inserite stringhe Python con il testo del messaggio, ma esiste un valore speciale None, che segnala alla coroutine msg_sender di interrompere il lavoro, per farle dimenticare il proprio writer collegato 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 l'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 riceve i messaggi in uscita (immessi nella coda dal socket in), li serializza in un messaggio MsgText e li invia tramite la connessione TCP. Essa può interrompersi in qualsiasi momento — questo lo gestiamo 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 nel socket out del corrispondente interlocutore. Perché non possiamo semplicemente utilizzare .read() e decodificare il messaggio? Perché non è escluso che più messaggi dell'utente vengano aggregati nel buffer del sistema operativo e inviati in un unico segmento TCP. Potremo decodificare il primo, ma nel buffer potrebbe rimanere una parte del successivo. In qualsiasi situazione anomala chiudiamo la connessione TCP e arrestiamo 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'iniziatore e compie esattamente le stesse azioni, ma il ciclo infinito di lettura dei messaggi viene avviato subito, per semplicità. Attualmente, il protocollo di handshake invia un messaggio da ciascun lato, ma in futuro, l'iniziatore della connessione invierà due messaggi, dopo i quali sarà subito possibile inviare messaggi di testo.

  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 inaspettato", _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: disconnettendo", _id)
 125     if msg_expected == "text":
 126         IN_QUEUES[peer_name].put(None)
 127     writer.close()

Protocollo sicuro

È tempo di rendere sicura la nostra comunicazione. Cosa intendiamo per sicurezza e cosa vogliamo:

  • riservatezza dei messaggi trasmessi;
  • autenticità e integrità dei messaggi trasmessi — eventuali modifiche devono essere rilevate;
  • protezione dagli attacchi di riproduzione (replay attack) — deve essere rilevato il fatto che messaggi siano scomparsi o ripetuti (e decidiamo di interrompere la connessione);
  • identificazione e autenticazione dei conversatori tramite chiavi pubbliche preimpostate — abbiamo già deciso in precedenza di realizzare una rete friend-to-friend. Solo dopo l'autenticazione capiremo con chi stiamo comunicando;
  • presenza di perfect forward secrecy proprietà (PFS) — la compromissione della nostra chiave di firma a lunga durata non deve consentire la lettura di tutta la corrispondenza precedente. La registrazione del traffico intercettato diventa inutile;
  • la validità dei messaggi (di trasporto e di handshake) è limitata a una singola sessione TCP. L'inserimento di messaggi correttamente firmati/autenticati da un'altra sessione (anche dallo stesso interlocutore) non dovrebbe essere possibile;
  • l'osservatore passivo non dovrebbe vedere né identificatori degli utenti, né chiavi pubbliche a lungo termine trasmesse, né gli hash di esse. Una certa anonimato dall'osservatore passivo.

Incredibile, ma praticamente tutti vogliono avere questo minimo in qualsiasi protocollo di handshake, e molto poco di quanto sopra viene effettivamente implementato per i protocolli "fatti in casa". E ora non inventiamo nulla di nuovo. Raccomanderei senza dubbio di utilizzare il framework Noise 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, problemi, vulnerabilità, mancanza di pianificazione, complessità e lacune (tuttavia, questo si applica poco a TLS 1.3). Ma non lo consideriamo a causa della sua eccessiva complessità.
  • IPsec con IKE — non hanno seri problemi crittografici, anche se non sono semplici. Se si legge di IKEv1 e IKEv2, la loro origine è data da STS, ISO/IEC IS 9798-3 e protocolli SIGMA (SIGn-and-MAc) — piuttosto facili da implementare in una sera.

Perché SIGMA, come ultimo anello di sviluppo dei protocolli STS/ISO, è buono? Soddisfa tutti i nostri requisiti (compresa la "nascondibilità" degli identificatori degli interlocutori), non ha problemi crittografici noti. È minimalista: la rimozione di almeno un elemento dal messaggio del protocollo porterà alla sua insicurezza.

Passiamo dal protocollo di base fatto in casa a SIGMA. L'operazione più basilare che ci interessa è la negoziazione delle chiavi: una funzione, in cui entrambi i partecipanti riceveranno lo stesso valore, che potrà essere utilizzato come chiave simmetrica. Senza entrare nel dettaglio: ciascuna delle parti genera una coppia di chiavi efemere (utilizzate solo all'interno di una singola sessione), scambia le chiavi pubbliche, chiama la funzione di negoziazione, alla quale forniscono la loro 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 nel mezzo e sostituire le chiavi pubbliche con le proprie — in questo protocollo non c'è autenticazione delle controparti. 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) ║   │
   │        ╚═════════════════════╝   │
   │                                  │

Tale firma non è appropriata, poiché non è legata a una sessione specifica. Questi messaggi "andranno bene" anche per sessioni con altri partecipanti. Deve essere firmato tutto il contesto. Questo costringe anche a inviare un ulteriore messaggio da A.

Inoltre, è fondamentale aggiungere alla firma anche il proprio identificatore, altrimenti potremmo sostituire IdXXX e risignare il messaggio con la chiave di un altro interlocutore noto. Per prevenire attacchi di riflessione, è necessario che gli elementi sotto la firma si trovino in posizioni chiaramente definite in base al 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, gli insiemi nella codifica 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 comune condivisa per questa sessione. In linea di principio, potremmo anche fare a meno di questo passaggio: il primo messaggio di trasporto sarà non valido, ma vogliamo essere certi che, una volta concluso il handshake, tutto sia effettivamente concordato. Al momento abbiamo a disposizione il protocollo ISO/IEC IS 9798-3.

Potremmo firmare anche la chiave generata. Questo è rischioso, poiché non è escluso che nell'algoritmo di firma utilizzato possano esserci perdite (siano esse bit di firma o meno). È possibile firmare l'hash della chiave generata, ma una perdita anche dell'hash della chiave generata può avere valore in caso di attacco 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, ...)║
   │                                                  │ ╚═════════════════════╝
   │                                                  │

Come ottimizzazione, alcuni potrebbero voler riutilizzare le proprie chiavi effimere (cosa che, 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 a metà 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. Grazie al binding tra il cookie e la chiave pubblica effimera, la chiave pubblica dell'altra parte può essere rimossa dalla firma per inutilizzo.

┌─────┐                                                                 ┌─────┐
│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 da osservatori passivi. A questo proposito, SIGMA propone di scambiare prima chiavi effimere, generare una chiave comune sulla quale cifrare i messaggi di autenticazione e identificazione. SIGMA descrive due varianti:

  • SIGMA-I — protegge l'iniziatore da attacchi attivi e il rispondente da attacchi passivi: l'iniziatore autentica il rispondente e, se qualcosa non torna, non rivela la propria identificazione. Il rispondente rivela la propria identificazione solo se inizia un protocollo attivo. Un osservatore passivo non apprende nulla;
    SIGMA-R — protegge il rispondente da attacchi attivi, l'iniziatore da attacchi passivi. Tutto è esattamente all'opposto, ma in questo protocollo vengono già trasmessi quattro messaggi di handshake.

    Scegliamo SIGMA-I poiché è più simile a ciò che ci aspettiamo da elementi client-server familiari: solo il server autenticato può riconoscere il client, mentre il server conosce già tutto. Inoltre, è più semplice da implementare a causa del numero ridotto di messaggi di handshake. Tutto ciò 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))) │ ╔═══════════════════════════╗
       │<─────────────────────────────────────────────────────────────────────────────│ ║SignPrvB, SignPubB = load()║
       │                                                                              │ ║PrvB, PubB = DHgen()       ║
       │                                                                              │ ╚═══════════════════════════╝
       │                                                                              │ ╔═════════════════════╗
       │       Enc((IdA, sign(SignPrvA, (CookieB, CookieA, PubA)), MAC(IdA)))         │ ║Key = DH(PrvA, PubB) ║
       │─────────────────────────────────────────────────────────────────────────────>│ ║verify(Key, IdB)     ║
       │                                                                              │ ║verify(SignPubB, ...)║
       │                                                                              │ ╚═════════════════════╝
       │                                                                              │
    
    • Per la firma si utilizza GOST R 34.10-2012 algoritmo con chiavi a 256 bit.
    • Per la generazione della chiave condivisa si utilizza 34.10-2012 VKO.
    • Come MAC si utilizza CMAC. Tecnicheamente, questo è un modo speciale di funzionamento di un cifrario a blocchi, descritto in GOST R 34.13-2015. Come funzione di crittografia per questo modo — Cicala (34.12-2015).
    • Come identificatore del corrispondente viene utilizzato un hash della sua chiave pubblica. Come hash viene applicato Stribog-256 (34.11-2012 256 bit).

    Dopo il handshake avremo una chiave condivisa concordata. Possiamo usarla per la crittografia autenticata dei messaggi di trasporto. Questa parte è piuttosto semplice e difficile da sbagliare: incrementiamo il contatore dei messaggi, crittografiamo il messaggio, autenticizziamo (MAC) il contatore e il testo crittografato, e inviamo. All'arrivo del messaggio verifichiamo che il contatore abbia il valore atteso, autenticizziamo il testo crittografato con il contatore, e decrittografiamo. Quale chiave utilizzare per crittografare i messaggi di handshake, di trasporto, quale autenticizzare? Usare una sola chiave per tutti questi compiti è pericoloso e irragionevole. È necessario generare chiavi utilizzando funzioni specializzate KDF (funzione di derivazione della chiave). Anche in questo caso, non complicheremo le cose o inventeremo nulla: HKDF è ben nota, ben studiata e non presenta problemi noti. Purtroppo, nella libreria nativa di Python non è presente questa funzione, quindi utilizziamo hkdf il pacchetto. HKDF utilizza internamente HMAC, che a sua volta utilizza una funzione hash. Un esempio di implementazione in Python sulla pagina Wikipedia richiede poche righe di codice. Come nel caso di 34.10-2012, utilizzeremo Stribog-256 come funzione hash. L'output della nostra funzione di concordanza delle chiavi sarà chiamato chiave di sessione, da cui saranno generate le chiavi 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/schema

    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 (da firmare). HandshakeTBE — ciò che sarà crittografato (da crittografare). Faccio notare il campo ukm in MsgHandshake1. 34.10 VKO, per una maggiore randomizzazione delle chiavi generate, include il parametro UKM (materiale di chiave utente) — semplicemente un'ulteriore entropia.

    Aggiunta di crittografia nel codice

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

    Poiché l'autenticazione e l'identificazione dei comunicanti saranno effettuate tramite chiavi pubbliche, ora devono essere memorizzate da qualche parte 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 private e pubbliche esadecimali. their — i nomi dei comunicanti e le loro chiavi pubbliche. Modifichiamo gli argomenti della riga di comando e aggiungiamo un post-processing dei dati JSON:

    da pygost import gost3410
    da pygost.gost34112012256 import GOST34112012256
    
    CURVA = 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(CURVA, gost3410.prv_unmarshal(prv_raw))
        pub_raw = gost3410.pub_marshal(pub)
        print(json.dumps({
            "nostro": {"prv": hexenc(prv_raw), "pub": hexenc(pub_raw)},
            "loro": {},
        }))
        exit(0)
    
    # Analizza e de-serializza 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["nostro"]["prv"]))
    _pub = hexdec(_keys["nostro"]["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["loro"].items():
        _pub = hexdec(pub_raw)
        KEYS[GOST34112012256(_pub).digest()] = {
            "nome": peer_name,
            "pub": gost3410.pub_unmarshal(_pub),
        }
    # }}}
    

    La chiave privata dell'algoritmo 34.10 è un numero casuale. Ha una dimensione di 256 bit per curve elliptiche a 256 bit. PyGOST non lavora con una serie di byte, ma con numeri grandi, pertanto la nostra chiave privata (urandom(32)) deve essere convertita in un numero utilizzando gost3410.prv_unmarshal(). La chiave pubblica viene calcolata in modo deterministico dalla chiave privata utilizzando gost3410.public_key(). La chiave pubblica 34.10 è composta da due numeri grandi, che devono essere anch'essi convertiti in una sequenza di byte per comodità di memorizzazione e trasmissione, utilizzando gost3410.pub_marshal().

    Dopo aver letto il file JSON, le chiavi pubbliche devono essere convertite di nuovo utilizzando gost3410.pub_unmarshal(). Poiché riceveremo gli identificatori dei partner come hash dalla chiave pubblica, possiamo calcolarli in anticipo e inserirli in un dizionario per una ricerca rapida. L'hash Streibog-256 è rappresentato da gost34112012256.GOST34112012256(), completamente compatibile con l'interfaccia hashlib delle funzioni di hash.

    Come è cambiata la coroutine dell'inizializzatore? Tutto come nello schema di handshake: generiamo un cookie (128 bit è più che sufficiente), una coppia di chiavi efemere 34.10, che verrà utilizzata per la funzione di concordanza delle chiavi VKO.

     395 async def initiator(host, port):
     396     _id = repr((host, port))
     397     logging.info("%s: composizione", _id)
     398     reader, writer = await asyncio.open_connection(host, port)
     399     # Generiamo la nostra chiave pubblica ephemerale e cookie, inviamo il messaggio 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 una risposta e decodifichiamo il messaggio Msg ricevuto;
    • ci assicuriamo di aver ricevuto handshake1;
    • decodifichiamo la chiave pubblica ephemerale dell'altra parte e calcoliamo la chiave di sessione;
    • generiamo le chiavi simmetriche necessarie per elaborare la parte TBE del messaggio.

     423     logging.info("%s: ricevuto messaggio %s", _id, msg.choice)
     424     if msg.choice != "handshake1":
     425         logging.warning("%s: messaggio inaspettato, disconnessione", _id)
     426         writer.close()
     427         return
     428     # }}}
     429     msg_handshake1 = msg.value
     430     # Convalida del messaggio di 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")
    

    L'UKM è un numero a 64 bit (urandom(8)), che richiede anch'esso la disserializzazione dalla rappresentazione byte, utilizzando gost3410_vko.ukm_unmarshal(). La funzione VKO per 34.10-2012 a 256 bit è gost3410_vko.kek_34102012256() (KEK — chiave di crittografia della chiave).

    La chiave di sessione generata è già una sequenza di byte pseudo-casuale a 256 bit. Può quindi essere immediatamente utilizzata nella funzione HKDF. Poiché GOST34112012256 soddisfa l'interfaccia hashlib, può essere utilizzata direttamente nella classe Hkdf. Non specifichiamo il sale (primo argomento di Hkdf) poiché la chiave generata, a causa dell'ephemeralità delle coppie di chiavi coinvolte, sarà diversa per ogni sessione ed avrà già sufficiente entropia. kdf.expand() restituisce per impostazione predefinita chiavi di lunghezza 256 bit, necessarie per l'Uccello di Fuoco in seguito.

    In seguito controlliamo le parti TBE e TBS del messaggio ricevuto:

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

     441     try:
     442         peer_name = validate_tbe(
     443             msg_handshake1,
     444             key_handshake1_mac_identity,
     445             key_handshake1_enc,
     446             key_handshake1_mac,
     447             cookie_our,
     448             cookie_their,
     449             pub_their_raw,
     450         )
     451     except ValueError as err:
     452         logging.warning("%s: %s, disconnessione", _id, err)
     453         writer.close()
     454         return
     455     # }}}
    
     128 def validate_tbe(
     129         msg_handshake: Union[MsgHandshake1, MsgHandshake2],
     130         key_mac_identity: bytes,
     131         key_enc: bytes,
     132         key_mac: bytes,
     133         cookie_their: Cookie,
     134         cookie_our: Cookie,
     135         pub_key_our: PubKey,
     136 ) -> str:
     137     ciphertext = bytes(msg_handshake["ciphertext"])
     138     mac_tag = mac(GOST3412Kuznechik(key_mac).encrypt, KUZNECHIK_BLOCKSIZE, ciphertext)
     139     if not compare_digest(mac_tag, bytes(msg_handshake["ciphertextMac"])):
     140         raise ValueError("MAC non valido")
     141     plaintext = ctr(
     142         GOST3412Kuznechik(key_enc).encrypt,
     143         KUZNECHIK_BLOCKSIZE,
     144         ciphertext,
     145         8 * b"x00",
     146     )
     147     try:
     148         tbe, _ = HandshakeTBE().decode(plaintext)
     149     except ASN1Error:
     150         raise ValueError("impossibile decodificare 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("identità sconosciuta")
     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("MAC identità non valido")
     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("firma non valida")
     174     return peer["name"]
    

    Come già scritto sopra, la 34.13-2015 descrive vari modalità di funzionamento degli algoritmi a blocchi dalla 34.12-2015. Tra queste c'è la modalità per la generazione di un'imbottitura, il calcolo del MAC. In PyGOST si utilizza gost3413.mac(). Questa modalità richiede la trasmissione della funzione di crittografia (che accetta e restituisce un blocco di dati), la dimensione del blocco di crittografia e, appunto, i dati stessi. Perché non si può definire hardcoded la dimensione del blocco di crittografia? La 34.12-2015 descrive non solo il cifrario a 128 bit Kuznechik, ma anche quello a 64 bit Magma — un'ulteriore modifica del GOST 28147-89, creato ancora in KGB e che mantiene ancora uno dei più alti standard di sicurezza.

    Il grasso viene inizializzato con gost.3412.GOST3412Kuznechik(key) chiamando e restituisce un oggetto con metodi .encrypt()/.decrypt() adatti per il trasferimento in funzioni 34.13. La MAC viene calcolata come segue: gost3413.mac(GOST3412Kuznechik(key).encrypt, KUZNECHIK_BLOCKSIZE, ciphertext). Per confrontare la MAC calcolata e quella ricevuta, non si può utilizzare un confronto normale (==) delle stringhe di byte, poiché questa operazione provoca perdite di tempo di confronto, il che, in generale, può portare a vulnerabilità fatali di tipo BEAST attacchi a TLS. In Python esiste una funzione speciale hmac.compare_digest per questo.

    La funzione del cifratore a blocchi può crittografare solo un blocco di dati. Per quantità maggiori, 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. Ognuna ha i propri ambiti di applicazione e caratteristiche. Con grande dispiacere, non abbiamo ancora modalità di crittografia autentificate standardizzate (tipo CCM, OCB, GCM e simili) — siamo costretti a aggiungere un MAC autonomamente. Scelgo la modalità contatore (CTR): non richiede di riempire fino alla dimensione del blocco, può essere parallelizzato, utilizza solo la funzione di crittografia e può essere utilizzato in modo sicuro per crittografare un gran numero di messaggi (a differenza di CBC, per il quale iniziano a verificarsi collisioni relativamente rapidamente). Come e .mac(), .ctr() accetta dati simili in ingresso: ciphertext = gost3413.ctr(GOST3412Kuznechik(key).encrypt, KUZNECHIK_BLOCKSIZE, plaintext, iv). È necessario specificare un vettore di inizializzazione, lungo esattamente la metà della dimensione del blocco di cifratura. Se la nostra chiave di crittografia viene utilizzata solo per crittografare un messaggio (anche se composto da più blocchi), è possibile specificare in sicurezza un vettore di inizializzazione nullo. Per crittografare i messaggi di handshake utilizziamo ogni volta una chiave separata.

    Il controllo della firma gost3410.verify() è triviale: passiamo la curva ellittica all'interno della quale operiamo (che fissiamo nel nostro protocollo GOSTIM), la chiave pubblica del firmatario (non dimentichiamo che deve essere una tupla di due 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 che abbiamo fatto durante il controllo, solo in modo simmetrico: la firma sulle nostre chiavi invece della verifica, e così via...

    Successivamente, nell'iniziatore prepariamo e inviamo il messaggio handshake2 per il handshake, compiendo le stesse operazioni che abbiamo fatto durante la verifica, solo in modo simmetrico: firmando le nostre chiavi invece di verificarle, 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)
     

    Quando la sessione è stabilita, vengono generati le chiavi di trasporto (una chiave separata per la crittografia, per l'autenticazione, per ciascuna delle parti), si inizializza il Kuznechik per la decrittografia e la verifica del MAC:

     499     # Esegui il mittente del messaggio 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     # Aspetta i messaggi di test {{{
     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 aumenta monotonamente, fungendo anche da vettore di inizializzazione per la crittografia in modalità contatore. Ad ogni messaggio e blocco di messaggi verranno garantiti valori diversi del contatore.

    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 inaspettato")
        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 è destinato esclusivamente a scopi didattici (dato che non ha copertura testuale, almeno)! È possibile scaricare il codice sorgente del programma qui (Hash Stribog-256: 995bbd368c04e50a481d138c5fa2e43ec7c89bc77743ba8dbabee1fde45de120). Come tutti i miei progetti, tipo GoGOST, (di cui ho già, NNCP, GoVPN, GOSTIM è completamente software libero, distribuito secondo i termini della GPLv3+.

    Sergey Matveev, cipherpunk, membro Fondo Soft Open, sviluppatore Python/Go, specialista 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