GOSTIM: P2P F2F E2EE IM in one evening with GOST cryptography
As a developer of the PyGOST library (GOST cryptographic primitives in pure Python), I often get questions about how to implement the simplest secure messaging on my knee. Many consider applied cryptography to be a fairly simple thing, and a .encrypt () call to a block cipher will be enough to send securely over a communication channel. Others believe that applied cryptography is the destiny of a few, and it is acceptable that wealthy companies such as Telegram with mathematics olympiads cannot implement a secure protocol.
All this prompted me to write this article to show that the implementation of cryptographic protocols and secure IM is not such a difficult task. However, inventing your own authentication and key agreement protocols is not worth it.

The article will be written peer-to-peer , friend-to-friend , end-to-end encrypted instant messenger with SIGMA-I authentication and key agreement protocol (based on which IPsec IKE is implemented ), using exclusively GOST cryptographic algorithms PyGOST library and ASN.1 encoding of messages with PyDERASN library (about which I already wrote before ). Prerequisite: it must be so simple that it can be written from scratch in one evening (or work day), otherwise it is no longer a simple program. It probably has errors, unnecessary difficulties, shortcomings, plus this is my first program using the asyncio library.
To get started, you need to understand how our IM will look. For simplicity, let it be a peer-to-peer network, without any discovery of participants. We will personally indicate to which address: the port to connect to communicate with the interlocutor.
I understand that at the moment, the assumption of the availability of direct communication between two arbitrary computers is a significant limitation of the applicability of IM in practice. But the more developers will implement all kinds of NAT-traversal crutches, the longer we will remain on the IPv4 Internet, with the depressing likelihood of communication between arbitrary computers. Well, how much can you endure the lack of IPv6 at home and at work?
We will have a friend-to-friend network: all possible interlocutors should be known in advance. Firstly, it greatly simplifies everything: introduced oneself, found or did not find a name / key, disconnected or continue to work, knowing the interlocutor. Secondly, in the general case, it is safe and excludes many attacks.
The IM interface will be close to the classic solutions of suckless projects , which I really like for their minimalism and Unix-way philosophy. An IM program for each interlocutor creates a directory with three Unix domain sockets:
In addition, a conn socket is created, writing to which host port, we initiate a connection to a remote interlocutor.
This approach allows you to make independent implementations of IM transport and the user interface, because there is no friend for the taste and color, you will not please everyone. Using tmux and / or multitail , you can get a multi-window interface with syntax highlighting. And with rlwrap, you can get a GNU Readline-compatible string for entering messages.
In fact, suckless projects use FIFO files. Personally, I could not understand how in asyncio to work with files competitively without a hand-made substrate from selected threads (I have been using Go language for such things for a long time ). Therefore, I decided to get by with the Unix domain sockets. Unfortunately, this makes it impossible to do echo 2001: 470: dead :: babe 6666> conn. I solved this problem usingsocat : echo 2001: 470: dead :: babe 6666 | socat - UNIX-CONNECT: conn, socat READLINE UNIX-CONNECT: alice / in.
TCP is used as a transport: it guarantees delivery and its order. UDP guarantees neither one nor the other (which would be useful when cryptography is applied), and SCTP support in Python is out of the box.
Unfortunately, in TCP there is no concept of a message, but only a stream of bytes. Therefore, it is necessary to come up with a format for messages so that they can be shared among themselves in this stream. We can agree to use the line feed character. For starters, it’s suitable, however, when we begin to encrypt our messages, this symbol may appear anywhere in the ciphertext. Therefore, protocols are popular on networks, sending first the length of the message in bytes. For example, in Python, out of the box there is xdrlib, which allows you to work with a similar XDR format .
We will not work correctly and efficiently with TCP reading - we simplify the code. We read the data from the socket in an endless loop until we decode the complete message. You can also use JSON with XML as a format for this approach. But when cryptography is added, then the data will have to be signed and authenticated - and this will require a byte-by-byte identical representation of objects, which JSON / XML does not provide (dumps may vary).
XDR is suitable for such a task, however, I choose ASN.1 with DER encoding and PyDERASN library, since we will have high-level objects on hand, which are often more pleasant and convenient to work with. Unlike schemaless bencode , MessagePack or CBOR, ASN.1 will automatically check the data against the hard-coded circuit.
The received message will be Msg: either a text MsgText (with one text field so far) or a handshake message MsgHandshake (in which the name of the interlocutor is transmitted). Now it looks overcomplicated, but it is a challenge for the future.
As I said, for all operations with sockets the asyncio library will be used. Declare what we expect at launch:
Set your own name (--our-name alice). A comma lists all expected interlocutors (--their-names bob, eve). For each of the interlocutors, a directory with Unix sockets is created, as well as a coroutine for each in, out, state:
Messages from the in socket from the user are sent to the queue IN_QUEUES:
Messages from interlocutors are sent to the OUT_QUEUES queue, from which data is written to the out socket:
When reading from the state socket, the program looks in the PEER_ALIVE dictionary for the address of the interlocutor. If there is no connection to the interlocutor yet, an empty line is written.
When an address is written to the conn socket, the “initiator” function of the connection is launched:
Consider the initiator. First, he obviously opens a connection to the specified host / port and sends a handshake message with his name:
Then it waits for a response from the remote side. Attempts to decode the received response according to the Msg ASN.1 scheme. We assume that the entire message will be sent by one TCP segment and we will receive it atomically when .read () is called. We verify that we received exactly the handshake message.
We verify that the name of the person we are talking to is known to us. If not, then break the connection. We check if we have already established a connection with him (the interlocutor again gave the command to connect to us) and close it. Python strings with message text are placed in the IN_QUEUES queue, but there is a special value None, which signals msg_sender to the coroutine to stop working so that she will forget about her writer related to the outdated TCP connection.
msg_sender accepts outgoing messages (queued from an in socket), serializes them into an MsgText message, and sends them over a TCP connection. It can break off at any moment - we are clearly intercepting it.
At the end, the initiator enters an endless cycle of reading messages from the socket. Checks if this is a text message, and places in the OUT_QUEUES the queue from which they will be sent to the out socket of the corresponding interlocutor. Why can't you just do .read () and decode the message? Because it is possible that several messages from the user will be aggregated in the buffer of the operating system and sent by one TCP segment. We can decode the first one, and then part of the subsequent one may remain in the buffer. In any emergency, we close the TCP connection and stop the msg_sender coroutine (by sending None to the OUT_QUEUES queue).
Let's go back to the main code. After creating all the coroutines, at the time of starting the program, we start the TCP server. For each established connection, he creates a responder coroutine.
The responder is similar to the initiator and mirrors all the same actions, but an endless loop of reading messages starts immediately, for simplicity. Now the handshake protocol sends one message from each side, but in the future, there will be two from the initiator of the connection, after which text messages can immediately be sent.
The time has come to secure our communication. What do we mean by security and what we want:
Surprisingly, almost everyone wants to have this minimum in any handshake protocol, and very few of the above are ultimately performed for home-grown protocols. So now we will not invent new things. I would definitely recommend using the Noise framework to build protocols, but let's choose something simpler.
The most popular are two protocols:
How is SIGMA, as the last link in the development of STS / ISO protocols, good? It satisfies all our requirements (including "hiding" the identifiers of the interlocutors), does not have known cryptographic problems. It is minimalistic - removing at least one element from the protocol message will lead to its insecurity.
Let's go from the simplest homegrown protocol to SIGMA. The most basic operation we are interested in is key negotiation: a function at the output of which both participants will receive the same value, which can be used as a symmetric key. Without going into details: each of the parties generates an ephemeral (used only within the same session) key pair (public and private keys), exchanges public keys, calls the reconciliation function, to the input of which they transmit their private key and the public key of the interlocutor.
Anyone can intervene in the middle and replace the public keys with their own - in this protocol there is no authentication of the interlocutors. Add a signature with long-lived keys.
Such a signature will not work, as it is not tied to a specific session. Such messages are also suitable for sessions with other participants. The whole context should be subscribed. This also makes it necessary to add the sending of another message from A.
In addition, it is critical to add our own identifier as a signature, since, otherwise, we can replace IdXXX and re-sign the message with the key of another well-known interlocutor. To prevent reflection attacks, it is necessary that the elements under the signature be in clearly defined places in their meaning: if A signs (PubA, PubB), then B must sign (PubB, PubA). This also indicates the importance of choosing the structure and format of serialized data. For example, sets in ASN.1 DER encoding are sorted: SET OF (PubA, PubB) will be identical to SET OF (PubB, PubA).
However, we still have not “proved” that we have developed the same common key for this session. In principle, you can do without this step - the first transport connection will be invalid, but we want that when the handshake is completed, we would be sure that everything is really agreed. At the moment, we have in our hands the ISO / IEC IS 9798-3 protocol.
We could sign the key itself. This is dangerous, since it is possible that there may be leaks in the signature algorithm used (let bits-per-signature, but still leaks). You can sign a hash from the generated key, but even a hash leak from the generated key can be of value in a brute-force attack on the generation function. SIGMA uses a MAC function that authenticates the sender ID.
As an optimization, some may want to reuse their ephemeral keys (which, of course, is deplorable for PFS). For example, we generated a key pair, tried to connect, but TCP was not available or broke off somewhere in the middle of the protocol. It is a pity to spend the entropy and processor resources spent on a new pair. Therefore, we introduce the so-called cookie - a pseudo-random value that will protect against possible accidental replay attacks when reusing ephemeral public keys. Due to the binding between the cookie and the ephemeral public key, the public key of the opposite party can be removed from the signature as unnecessary.
Finally, we want to obtain the privacy of our interlocutor identifiers from a passive observer. To do this, SIGMA suggests first exchanging ephemeral keys, working out a common key on which to authenticate authentication messages. SIGMA describes two options:
All this prompted me to write this article to show that the implementation of cryptographic protocols and secure IM is not such a difficult task. However, inventing your own authentication and key agreement protocols is not worth it.

The article will be written peer-to-peer , friend-to-friend , end-to-end encrypted instant messenger with SIGMA-I authentication and key agreement protocol (based on which IPsec IKE is implemented ), using exclusively GOST cryptographic algorithms PyGOST library and ASN.1 encoding of messages with PyDERASN library (about which I already wrote before ). Prerequisite: it must be so simple that it can be written from scratch in one evening (or work day), otherwise it is no longer a simple program. It probably has errors, unnecessary difficulties, shortcomings, plus this is my first program using the asyncio library.
Design IM
To get started, you need to understand how our IM will look. For simplicity, let it be a peer-to-peer network, without any discovery of participants. We will personally indicate to which address: the port to connect to communicate with the interlocutor.
I understand that at the moment, the assumption of the availability of direct communication between two arbitrary computers is a significant limitation of the applicability of IM in practice. But the more developers will implement all kinds of NAT-traversal crutches, the longer we will remain on the IPv4 Internet, with the depressing likelihood of communication between arbitrary computers. Well, how much can you endure the lack of IPv6 at home and at work?
We will have a friend-to-friend network: all possible interlocutors should be known in advance. Firstly, it greatly simplifies everything: introduced oneself, found or did not find a name / key, disconnected or continue to work, knowing the interlocutor. Secondly, in the general case, it is safe and excludes many attacks.
The IM interface will be close to the classic solutions of suckless projects , which I really like for their minimalism and Unix-way philosophy. An IM program for each interlocutor creates a directory with three Unix domain sockets:
- in - messages sent to the interlocutor are recorded in it;
- out - messages received from the interlocutor are read from it;
- state - reading from it, we will find out if the interlocutor is connected now, the connection address / port.
In addition, a conn socket is created, writing to which host port, we initiate a connection to a remote interlocutor.
| - alice | | - in | | - out | `- state | - bob | | - in | | - out | `- state `- conn
This approach allows you to make independent implementations of IM transport and the user interface, because there is no friend for the taste and color, you will not please everyone. Using tmux and / or multitail , you can get a multi-window interface with syntax highlighting. And with rlwrap, you can get a GNU Readline-compatible string for entering messages.
In fact, suckless projects use FIFO files. Personally, I could not understand how in asyncio to work with files competitively without a hand-made substrate from selected threads (I have been using Go language for such things for a long time ). Therefore, I decided to get by with the Unix domain sockets. Unfortunately, this makes it impossible to do echo 2001: 470: dead :: babe 6666> conn. I solved this problem usingsocat : echo 2001: 470: dead :: babe 6666 | socat - UNIX-CONNECT: conn, socat READLINE UNIX-CONNECT: alice / in.
Initial unsafe protocol
TCP is used as a transport: it guarantees delivery and its order. UDP guarantees neither one nor the other (which would be useful when cryptography is applied), and SCTP support in Python is out of the box.
Unfortunately, in TCP there is no concept of a message, but only a stream of bytes. Therefore, it is necessary to come up with a format for messages so that they can be shared among themselves in this stream. We can agree to use the line feed character. For starters, it’s suitable, however, when we begin to encrypt our messages, this symbol may appear anywhere in the ciphertext. Therefore, protocols are popular on networks, sending first the length of the message in bytes. For example, in Python, out of the box there is xdrlib, which allows you to work with a similar XDR format .
We will not work correctly and efficiently with TCP reading - we simplify the code. We read the data from the socket in an endless loop until we decode the complete message. You can also use JSON with XML as a format for this approach. But when cryptography is added, then the data will have to be signed and authenticated - and this will require a byte-by-byte identical representation of objects, which JSON / XML does not provide (dumps may vary).
XDR is suitable for such a task, however, I choose ASN.1 with DER encoding and PyDERASN library, since we will have high-level objects on hand, which are often more pleasant and convenient to work with. Unlike schemaless bencode , MessagePack or CBOR, ASN.1 will automatically check the data against the hard-coded circuit.
# 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))),
))
The received message will be Msg: either a text MsgText (with one text field so far) or a handshake message MsgHandshake (in which the name of the interlocutor is transmitted). Now it looks overcomplicated, but it is a challenge for the future.
┌─────┐ ┌─────┐ │PeerA│ │PeerB│ └──┬──┘ └──┬──┘ │MsgHandshake (IdA) │ │─────────────────>> │ │ │ │MsgHandshake (IdB) │ │ <─────────────────│ │ │ │ MsgText () │ │─────────────────>> │ │ │ │ MsgText () │ │ <─────────────────│ │ │
IM without cryptography
As I said, for all operations with sockets the asyncio library will be used. Declare what we expect at launch:
parser = argparse.ArgumentParser(description="GOSTIM")
parser.add_argument(
"--our-name",
required=True,
help="Our peer name",
)
parser.add_argument(
"--their-names",
required=True,
help="Their peer names, comma-separated",
)
parser.add_argument(
"--bind",
default="::1",
help="Address to listen on",
)
parser.add_argument(
"--port",
type=int,
default=6666,
help="Port to listen on",
)
args = parser.parse_args()
OUR_NAME = UTF8String(args.our_name)
THEIR_NAMES = set(args.their_names.split(","))
Set your own name (--our-name alice). A comma lists all expected interlocutors (--their-names bob, eve). For each of the interlocutors, a directory with Unix sockets is created, as well as a coroutine for each 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"))
Messages from the in socket from the user are sent to the queue 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"))
Messages from interlocutors are sent to the OUT_QUEUES queue, from which data is written to the out socket:
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()
When reading from the state socket, the program looks in the PEER_ALIVE dictionary for the address of the interlocutor. If there is no connection to the interlocutor yet, an empty line is written.
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()
When an address is written to the conn socket, the “initiator” function of the connection is launched:
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))
Consider the initiator. First, he obviously opens a connection to the specified host / port and sends a handshake message with his name:
130 async def initiator(host, port):
131 _id = repr((host, port))
132 logging.info("%s: dialing", _id)
133 reader, writer = await asyncio.open_connection(host, port)
134 # Handshake message {{{
135 writer.write(Msg(("handshake", MsgHandshake((
136 ("peerName", OUR_NAME),
137 )))).encode())
138 # }}}
139 await writer.drain()
Then it waits for a response from the remote side. Attempts to decode the received response according to the Msg ASN.1 scheme. We assume that the entire message will be sent by one TCP segment and we will receive it atomically when .read () is called. We verify that we received exactly the handshake message.
141 # Wait for Handshake message {{{
142 data = await reader.read(256)
143 if data == b"":
144 logging.warning("%s: no answer, disconnecting", _id)
145 writer.close()
146 return
147 try:
148 msg, _ = Msg().decode(data)
149 except ASN1Error:
150 logging.warning("%s: undecodable answer, disconnecting", _id)
151 writer.close()
152 return
153 logging.info("%s: got %s message", _id, msg.choice)
154 if msg.choice != "handshake":
155 logging.warning("%s: unexpected message, disconnecting", _id)
156 writer.close()
157 return
158 # }}}
We verify that the name of the person we are talking to is known to us. If not, then break the connection. We check if we have already established a connection with him (the interlocutor again gave the command to connect to us) and close it. Python strings with message text are placed in the IN_QUEUES queue, but there is a special value None, which signals msg_sender to the coroutine to stop working so that she will forget about her writer related to the outdated TCP connection.
159 msg_handshake = msg.value
160 peer_name = str(msg_handshake["peerName"])
161 if peer_name not in THEIR_NAMES:
162 logging.warning("unknown peer name: %s", peer_name)
163 writer.close()
164 return
165 logging.info("%s: session established: %s", _id, peer_name)
166 # Run text message sender, initialize transport decoder {{{
167 peer_alive = PEER_ALIVES.pop(peer_name, None)
168 if peer_alive is not None:
169 peer_alive.close()
170 await IN_QUEUES[peer_name].put(None)
171 PEER_ALIVES[peer_name] = writer
172 asyncio.ensure_future(msg_sender(peer_name, writer))
173 # }}}
msg_sender accepts outgoing messages (queued from an in socket), serializes them into an MsgText message, and sends them over a TCP connection. It can break off at any moment - we are clearly intercepting it.
async def msg_sender(peer_name: str, writer) -> None:
in_queue = IN_QUEUES[peer_name]
while True:
text = await in_queue.get()
if text is None:
break
writer.write(Msg(("text", MsgText((
("text", UTF8String(text)),
)))).encode())
try:
await writer.drain()
except ConnectionResetError:
del PEER_ALIVES[peer_name]
return
logging.info("%s: sent %d characters message", peer_name, len(text))
At the end, the initiator enters an endless cycle of reading messages from the socket. Checks if this is a text message, and places in the OUT_QUEUES the queue from which they will be sent to the out socket of the corresponding interlocutor. Why can't you just do .read () and decode the message? Because it is possible that several messages from the user will be aggregated in the buffer of the operating system and sent by one TCP segment. We can decode the first one, and then part of the subsequent one may remain in the buffer. In any emergency, we close the TCP connection and stop the msg_sender coroutine (by sending None to the OUT_QUEUES queue).
174 buf = b""
175 # Wait for test messages {{{
176 while True:
177 data = await reader.read(MaxMsgLen)
178 if data == b"":
179 break
180 buf += data
181 if len(buf) > MaxMsgLen:
182 logging.warning("%s: max buffer size exceeded", _id)
183 break
184 try:
185 msg, tail = Msg().decode(buf)
186 except ASN1Error:
187 continue
188 buf = tail
189 if msg.choice != "text":
190 logging.warning("%s: unexpected %s message", _id, msg.choice)
191 break
192 try:
193 await msg_receiver(msg.value, peer_name)
194 except ValueError as err:
195 logging.warning("%s: %s", err)
196 break
197 # }}}
198 logging.info("%s: disconnecting: %s", _id, peer_name)
199 IN_QUEUES[peer_name].put(None)
200 writer.close()
66 async def msg_receiver(msg_text: MsgText, peer_name: str) -> None:
67 text = str(msg_text["text"])
68 logging.info("%s: received %d characters message", peer_name, len(text))
69 await OUT_QUEUES[peer_name].put(text)
Let's go back to the main code. After creating all the coroutines, at the time of starting the program, we start the TCP server. For each established connection, he creates a responder coroutine.
logging.basicConfig(
level=logging.INFO,
format="%(levelname)s %(asctime)s: %(funcName)s: %(message)s",
)
loop = asyncio.get_event_loop()
server = loop.run_until_complete(asyncio.start_server(responder, args.bind, args.port))
logging.info("Listening on: %s", server.sockets[0].getsockname())
loop.run_forever()
The responder is similar to the initiator and mirrors all the same actions, but an endless loop of reading messages starts immediately, for simplicity. Now the handshake protocol sends one message from each side, but in the future, there will be two from the initiator of the connection, after which text messages can immediately be sent.
72 async def responder(reader, writer):
73 _id = writer.get_extra_info("peername")
74 logging.info("%s: connected", _id)
75 buf = b""
76 msg_expected = "handshake"
77 peer_name = None
78 while True:
79 # Read until we get Msg message {{{
80 data = await reader.read(MaxMsgLen)
81 if data == b"":
82 logging.info("%s: closed connection", _id)
83 break
84 buf += data
85 if len(buf) > MaxMsgLen:
86 logging.warning("%s: max buffer size exceeded", _id)
87 break
88 try:
89 msg, tail = Msg().decode(buf)
90 except ASN1Error:
91 continue
92 buf = tail
93 # }}}
94 if msg.choice != msg_expected:
95 logging.warning("%s: unexpected %s message", _id, msg.choice)
96 break
97 if msg_expected == "text":
98 try:
99 await msg_receiver(msg.value, peer_name)
100 except ValueError as err:
101 logging.warning("%s: %s", err)
102 break
103 # Process Handshake message {{{
104 elif msg_expected == "handshake":
105 logging.info("%s: got %s message", _id, msg_expected)
106 msg_handshake = msg.value
107 peer_name = str(msg_handshake["peerName"])
108 if peer_name not in THEIR_NAMES:
109 logging.warning("unknown peer name: %s", peer_name)
110 break
111 writer.write(Msg(("handshake", MsgHandshake((
112 ("peerName", OUR_NAME),
113 )))).encode())
114 await writer.drain()
115 logging.info("%s: session established: %s", _id, peer_name)
116 peer_alive = PEER_ALIVES.pop(peer_name, None)
117 if peer_alive is not None:
118 peer_alive.close()
119 await IN_QUEUES[peer_name].put(None)
120 PEER_ALIVES[peer_name] = writer
121 asyncio.ensure_future(msg_sender(peer_name, writer))
122 msg_expected = "text"
123 # }}}
124 logging.info("%s: disconnecting", _id)
125 if msg_expected == "text":
126 IN_QUEUES[peer_name].put(None)
127 writer.close()
Secure protocol
The time has come to secure our communication. What do we mean by security and what we want:
- confidentiality of transmitted messages;
- authenticity and integrity of transmitted messages - their change must be detected;
- protection against replay attacks - the fact of the loss or replay of messages should be detected (and we decide to break the connection);
- identification and authentication of interlocutors by pre-driven public keys - we have already decided earlier that we are making a friend-to-friend network. Only after authentication will we understand with whom we are communicating;
- the presence of perfect forward secrecy properties (PFS) - the compromise of our long-lived signature key should not lead to the possibility of reading all the previous correspondence. Recording intercepted traffic becomes useless;
- validity / validity of messages (transport and handshakes) only within the same TCP session. Insertion of correctly signed / authenticated messages from another session (even with the same interlocutor) should not be possible;
- the passive observer should not see user identifiers, transmitted long-lived public keys, nor hashes from them. Some kind of anonymity from a passive observer.
Surprisingly, almost everyone wants to have this minimum in any handshake protocol, and very few of the above are ultimately performed for home-grown protocols. So now we will not invent new things. I would definitely recommend using the Noise framework to build protocols, but let's choose something simpler.
The most popular are two protocols:
- TLS is a complex protocol with a long history of bugs, schools, vulnerabilities, poor thought-out, complexity and shortcomings (however, this does not apply much to TLS 1.3). But we do not consider it because of the complexity.
- IPsec with IKE - do not have serious cryptographic problems, although they are also not simple. If you read about IKEv1 and IKEv2, their source is STS , ISO / IEC IS 9798-3 and SIGMA (SIGn-and-MAc) protocols - simple enough to implement in one evening.
How is SIGMA, as the last link in the development of STS / ISO protocols, good? It satisfies all our requirements (including "hiding" the identifiers of the interlocutors), does not have known cryptographic problems. It is minimalistic - removing at least one element from the protocol message will lead to its insecurity.
Let's go from the simplest homegrown protocol to SIGMA. The most basic operation we are interested in is key negotiation: a function at the output of which both participants will receive the same value, which can be used as a symmetric key. Without going into details: each of the parties generates an ephemeral (used only within the same session) key pair (public and private keys), exchanges public keys, calls the reconciliation function, to the input of which they transmit their private key and the public key of the interlocutor.
┌─────┐ ┌─────┐ │PeerA│ │PeerB│ └──┬──┘ └──┬──┘ │ IdA, PubA │ ╔════════════════════╗ │──────────────>> │ rPrvA, PubA = DHgen () ║ │ │ ╚═══════════════════╝ │ IdB, PubB │ ╔════════════════════╗ │ <───────────────│ ║PrvB, PubB = DHgen () ║ │ │ ╚═══════════════════╝ ────┐ ╔═══════════════════╗ │ ║Key = DH (PrvA, PubB) ║ <───┘ ╚═══════╤═══════════╝ │ │ │ │
Anyone can intervene in the middle and replace the public keys with their own - in this protocol there is no authentication of the interlocutors. Add a signature with long-lived keys.
┌─────┐ ┌─────┐ │PeerA│ │PeerB│ └──┬──┘ └──┬──┘ │IdA, PubA, sign (SignPrvA, (PubA)) │ ╔═══════════════════════╗ │ ─ ─ ─ ─ ign ign ign ign ign ign SignPrvA, SignPubA = load () ║ │ │ ║PrvA, PubA = DHgen () ║ │ │ ╚══ ═ ═ ═ ═ ╝ ╝ ═ ╝ ╝ ╝ ╝ ╝ │IdB, PubB, sign (SignPrvB, (PubB)) │ ╔═══════════════════════╗ │ <──── ─ ─ ─ ─ ─ ─ ─ SignPrvB, SignPubB = load () ║ │ │ ║PrvB, PubB = DHgen () ║ │ │ ╚══ ═ ═ ═ ═ ╝ ╝ ═ ╝ ╝ ╝ ╝ ╝ ────┐ ╔═════════════════════│ │ │ ║verify (SignPubB, ...) ║ │ <───┘ ║Key = DH (PrvA, PubB) ║ │ │ ╚═════════════════════╝ │ │ │
Such a signature will not work, as it is not tied to a specific session. Such messages are also suitable for sessions with other participants. The whole context should be subscribed. This also makes it necessary to add the sending of another message from A.
In addition, it is critical to add our own identifier as a signature, since, otherwise, we can replace IdXXX and re-sign the message with the key of another well-known interlocutor. To prevent reflection attacks, it is necessary that the elements under the signature be in clearly defined places in their meaning: if A signs (PubA, PubB), then B must sign (PubB, PubA). This also indicates the importance of choosing the structure and format of serialized data. For example, sets in ASN.1 DER encoding are sorted: SET OF (PubA, PubB) will be identical to SET OF (PubB, PubA).
┌─────┐ ┌─────┐ │PeerA│ │PeerB│ └──┬──┘ └──┬──┘ │ IdA, PubA │ ╔══════════════════════════ │─ ─ ─ ─ ─ ─ ign ign ign ign ign ign ign ign ign ign ign ign ign ign ign ign ign ign ign ign ign ign ign ign ign ign ign ign ign ign ign ign ign ign ign ign ign ign SignPubA = load () ║ │ │ ║PrvA, PubA = DHgen () ║ │ │ ╚══ ═ ═ ═ ═ ╝ ╝ ═ ╝ ╝ ╝ ╝ ╝ │IdB, PubB, sign (SignPrvB, (IdB, PubA, PubB)) │ ╔════════════════════╗ │ <──────────────────────────────────────────── ║SignPrvB, SignPubB = load () ║ │ │ ║PrvB, PubB = DHgen () ║ │ │ ╚══ ═ ═ ═ ═ ╝ ╝ ═ ╝ ╝ ╝ ╝ ╝ │ sign (SignPrvA, (IdA, PubB, PubA)) │ ╔═══════════════════╗ │ ─ ─ ─ ─ ─> ify ify ify ify ify ify ify ify ify ify ify ify ify ify ify ify ify ify ify SignPubB, ...) ║ │ │ ║Key = DH (PrvA, PubB) ║ │ │ ╚══ ═ ═ ═ ╝ ╝ ╝ ═ ╝ ╝ ╝ │ │
However, we still have not “proved” that we have developed the same common key for this session. In principle, you can do without this step - the first transport connection will be invalid, but we want that when the handshake is completed, we would be sure that everything is really agreed. At the moment, we have in our hands the ISO / IEC IS 9798-3 protocol.
We could sign the key itself. This is dangerous, since it is possible that there may be leaks in the signature algorithm used (let bits-per-signature, but still leaks). You can sign a hash from the generated key, but even a hash leak from the generated key can be of value in a brute-force attack on the generation function. SIGMA uses a MAC function that authenticates the sender ID.
┌─────┐ ┌─────┐ │PeerA│ │PeerB│ └──┬──┘ └──┬──┘ │ IdA, PubA │ ╔══════════════════════════ ││ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ > │ ║SignPrvA, SignPubA = load () ║ │ │ ║PrvA, PubA = DHgen () ║ │ │ ╚══ ═ ═ ═ ═ ╝ ╝ ═ ╝ ╝ ╝ ╝ ╝ │IdB, PubB, sign (SignPrvB, (PubA, PubB)), MAC (IdB) │ ╔═════════════════╗ │ <──── ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │ │ ─ ─ ─ ─ ─│ ║SignPrvB, SignPubB = load () ║ │ │ ║PrvB, PubB = DHgen () ║ │ │ ╚══ ═ ═ ═ ═ ╝ ╝ ═ ╝ ╝ ╝ ╝ ╝ │ │ ╔══ ═ ═ ═ ╗ ╗ ╗ ═ ╗ ╗ ╗ │ sign (SignPrvA, (PubB, PubA)), MAC (IdA) │ ║Key = DH (PrvA, PubB) ║ ││ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ > │ ║verify (Key, IdB) ║ │ │ ║verify (SignPubB, ...) ║ │ │ ╚══ ═ ═ ═ ╝ ╝ ╝ ═ ╝ ╝ ╝ │ │
As an optimization, some may want to reuse their ephemeral keys (which, of course, is deplorable for PFS). For example, we generated a key pair, tried to connect, but TCP was not available or broke off somewhere in the middle of the protocol. It is a pity to spend the entropy and processor resources spent on a new pair. Therefore, we introduce the so-called cookie - a pseudo-random value that will protect against possible accidental replay attacks when reusing ephemeral public keys. Due to the binding between the cookie and the ephemeral public key, the public key of the opposite party can be removed from the signature as unnecessary.
┌─────┐ ┌─────┐ │PeerA│ │PeerB│ └──┬──┘ └──┬──┘ │ IdA, PubA, CookieA │ ╔═════════════════════════ │ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │ ─ │ ─ ─ │ ─ │ │ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ────────────────────>> │ │ SignPrvA, SignPubA = load () ║ │ │ ║PrvA, PubA = DHgen () ║ │ │ ╚══ ═ ═ ═ ═ ╝ ╝ ═ ╝ ╝ ╝ ╝ ╝ │IdB, PubB, CookieB, sign (SignPrvB, (CookieA, CookieB, PubB)), MAC (IdB) │ ╔═══════════════════════ ═══╗ │ <──── ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │ │ ─ ─ ─ ─ ─ ─ ─ │ │ ─ ──────────────────────│ ║SignPrvB, SignPubB = load () ║ │ │ ║PrvB, PubB = DHgen () ║ │ │ ╚══ ═ ═ ═ ═ ╝ ╝ ═ ╝ ╝ ╝ ╝ ╝ │ │ ╔══ ═ ═ ═ ╗ ╗ ╗ ═ ╗ ╗ ╗ │ sign (SignPrvA, (CookieB, CookieA, PubA)), MAC (IdA) │ ║Key = DH (PrvA, PubB) ║ │ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │ ─ │ ─ ─ │ ─ │ │ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ify ify ify ify ify ify ify ify ify ify ify ify Verify (Key, IdB) ║ │ │ ║verify (SignPubB, ...) ║ │ │ ╚══ ═ ═ ═ ╝ ╝ ╝ ═ ╝ ╝ ╝ │ │
Finally, we want to obtain the privacy of our interlocutor identifiers from a passive observer. To do this, SIGMA suggests first exchanging ephemeral keys, working out a common key on which to authenticate authentication messages. SIGMA describes two options:
- SIGMA-I - protects the initiator from active attacks, the responder from passive ones: the initiator authenticates the responder and if something does not fit, then it does not give out its identification. The defendant gives his identification if you start an active protocol with him. The passive observer will not know anything;
SIGMA-R - protects the responder from active attacks, the initiator from passive. Everything is exactly the opposite, but in this protocol already four handshake messages are transmitted.
We select SIGMA-I as more similar to what we expect from the usual server-client things: only an authenticated server recognizes the client, and everyone knows the server anyway. Plus it is easier to implement due to fewer handshake messages. All we add to the protocol is the encryption of the message part and the transfer of identifier A to the encrypted part of the last message:┌─────┐ ┌─────┐ │PeerA│ │PeerB│ └──┬──┘ └──┬──┘ │ PubA, CookieA │ ╔══════════════════════════╗ │ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │ ─ │ ─ ─ │ ─ │ │ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ign ign ign ign ign ign ign ign ign ign ║ ign,,,,,, Sign Sign Sign Sign Sign Sign Sign load load load load load load load ((((((((((( │ │ ║PrvA, PubA = DHgen () ║ │ │ ╚══ ═ ═ ═ ═ ╝ ╝ ═ ╝ ╝ ╝ ╝ ╝ │PubB, CookieB, Enc ((IdB, sign (SignPrvB, (CookieA, CookieB, PubB)), MAC (IdB))) │ ╔══════════════════ ════════╗ │ <─── ─ ─ ─ ─ ─ │ │ │ │ │ │ ─ ign ign ign ign ign ign ign ─ ign ign ign ign ign ign,,,,,, Sign Sign Sign Sign Sign Sign = load load load load load ((((((((( │ │ ║PrvB, PubB = DHgen () ║ │ │ ╚══ ═ ═ ═ ═ ╝ ╝ ═ ╝ ╝ ╝ ╝ ╝ │ │ ╔══ ═ ═ ═ ╗ ╗ ╗ ═ ╗ ╗ ╗ │ Enc ((IdA, sign (SignPrvA, (CookieB, CookieA, PubA)), MAC (IdA))) │ ║Key = DH (PrvA, PubB) ║ ││ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ify ify ify ify ify ify ify ify ify ify ify ify ify ify ify ify ify ify ify ify ify ify ify ify ify ify ify ify ify ify ify ify Verify (Key, IdB) ║ │ │ ║verify (SignPubB, ...) ║ │ │ ╚══ ═ ═ ═ ╝ ╝ ╝ ═ ╝ ╝ ╝ │ │
- For signature, the GOST R 34.10-2012 algorithm with 256-bit keys is used.
- To generate a common key, 34.10-2012 VKO is used.
- The MAC used is CMAC. Technically, this is a special mode of operation for a block cipher described in GOST R 34.13-2015. As an encryption function for this mode - Grasshopper (34.12-2015).
- The identifier of the interlocutor uses a hash from his public key. As a hash, Stribog-256 (34.11-2012 256 bits) is used.
After the handshake, we will agree on a common key. We can use it for authenticated encryption of transport messages. This part is quite simple and it is difficult to make a mistake: we increment the message counter, encrypt the message, authenticate (MAC) the counter and ciphertext, send it. When receiving a message, we verify that the counter has the expected value, authenticate the ciphertext with the counter, and decrypt it. What key to encrypt handshake messages, transport, how to authenticate? Using one key for all these tasks is dangerous and unreasonable. It is necessary to generate keys using specialized KDF (key derivation function) functions. Again, let’s not invent and invent something: HKDFlong known, well researched and has no known problems. Unfortunately, the native Python library does not have this function, so we use the hkdf package. HKDF internally uses HMAC , which in turn uses a hash function. An example implementation in Python on a Wikipedia page takes a few lines of code. As in the case of October 34, 2012, we will use Stribog-256 as a hash function. The output of our key matching function will be called a session key, from which the missing symmetric keys will be generated: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")
Structures / Schemes
Let's consider what ASN.1 structures we have now obtained for transferring all this data: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 is what will be signed. HandshakeTBE is what will be encrypted. I draw attention to the ukm field in MsgHandshake1. 34.10 VKO, for even more randomization of generated keys, includes the UKM (user keying material) parameter - just an additional entropy.Adding cryptography to code
We will only consider the changes made to the original code, since the framework has remained the same (in fact, the final implementation was first written, and then all cryptography was sawn out of it).
Since the authentication and identification of the interlocutors will be carried out using public keys, now they need to be stored somewhere for a long time. For simplicity, we use JSON of this kind:{ "our": { "prv": "21254cf66c15e0226ef2669ceee46c87b575f37f9000272f408d0c9283355f98", "pub": "938c87da5c55b27b7f332d91b202dbef2540979d6ceaa4c35f1b5bfca6df47df0bdae0d3d82beac83cec3e353939489d9981b7eb7a3c58b71df2212d556312a1" }, "their": { "alice": "d361a59c25d2ca5a05d21f31168609deeec100570ac98f540416778c93b2c7402fd92640731a707ec67b5410a0feae5b78aeec93c4a455a17570a84f2bc21fce", "bob": "aade1207dd85ecd283272e7b69c078d5fae75b6e141f7649ad21962042d643512c28a2dbdc12c7ba40eb704af920919511180c18f4d17e07d7f5acd49787224a" } }
our is our key pair, hexadecimal private and public keys. their - the names of the interlocutors and their public keys. Change the command line arguments and add post-processing of JSON data:from pygost import gost3410 from pygost.gost34112012256 import GOST34112012256 CURVE = gost3410.GOST3410Curve( *gost3410.CURVE_PARAMS["GostR3410_2001_CryptoPro_A_ParamSet"] ) parser = argparse.ArgumentParser(description="GOSTIM") parser.add_argument( "--keys-gen", action="store_true", help="Generate JSON with our new keypair", ) parser.add_argument( "--keys", default="keys.json", required=False, help="JSON with our and their keys", ) parser.add_argument( "--bind", default="::1", help="Address to listen on", ) parser.add_argument( "--port", type=int, default=6666, help="Port to listen on", ) args = parser.parse_args() if args.keys_gen: prv_raw = urandom(32) pub = gost3410.public_key(CURVE, gost3410.prv_unmarshal(prv_raw)) pub_raw = gost3410.pub_marshal(pub) print(json.dumps({ "our": {"prv": hexenc(prv_raw), "pub": hexenc(pub_raw)}, "their": {}, })) exit(0) # Parse and unmarshal our and their keys {{{ with open(args.keys, "rb") as fd: _keys = json.loads(fd.read().decode("utf-8")) KEY_OUR_SIGN_PRV = gost3410.prv_unmarshal(hexdec(_keys["our"]["prv"])) _pub = hexdec(_keys["our"]["pub"]) KEY_OUR_SIGN_PUB = gost3410.pub_unmarshal(_pub) KEY_OUR_SIGN_PUB_HASH = OctetString(GOST34112012256(_pub).digest()) for peer_name, pub_raw in _keys["their"].items(): _pub = hexdec(pub_raw) KEYS[GOST34112012256(_pub).digest()] = { "name": peer_name, "pub": gost3410.pub_unmarshal(_pub), } # }}}
The private key of algorithm 34.10 is a random number. 256-bit size for 256-bit elliptical curves. PyGOST does not work with a set of bytes, but with large numbers , so our private key (urandom (32)) must be converted to a number using gost3410.prv_unmarshal (). The public key is deterministically calculated from the private key using gost3410.public_key (). Public key 34.10 - two large numbers, which also need to be converted to a byte sequence for the convenience of storage and transmission, using gost3410.pub_marshal ().
After reading the JSON file, the public keys, respectively, need to be converted back using gost3410.pub_unmarshal (). Since we will receive the identifiers of the interlocutors in the form of a hash from the public key, they can be immediately calculated in advance and put into the dictionary for quick search. Stribog-256 hash is gost34112012256.GOST34112012256 (), fully satisfying hashlib hash function interface.
How has the initiator corutin changed? Everything, as in the handshake scheme: we generate a cookie (128-bit is enough), an ephemeral key pair 34.10, which will be used for the VKO key matching function.395 async def initiator(host, port): 396 _id = repr((host, port)) 397 logging.info("%s: dialing", _id) 398 reader, writer = await asyncio.open_connection(host, port) 399 # Generate our ephemeral public key and cookie, send Handshake 0 message {{{ 400 cookie_our = Cookie(urandom(16)) 401 prv = gost3410.prv_unmarshal(urandom(32)) 402 pub_our = gost3410.public_key(CURVE, prv) 403 pub_our_raw = PubKey(gost3410.pub_marshal(pub_our)) 404 writer.write(Msg(("handshake0", MsgHandshake0(( 405 ("cookieInitiator", cookie_our), 406 ("pubKeyInitiator", pub_our_raw), 407 )))).encode()) 408 # }}} 409 await writer.drain()
- wait for a response and decode the received Msg message;
- make sure to get handshake1;
- decode the ephemeral public key of the opposite side and calculate the session key;
- вырабатываем симметричные ключи необходимые для обработки TBE части сообщения.
423 logging.info("%s: got %s message", _id, msg.choice) 424 if msg.choice != "handshake1": 425 logging.warning("%s: unexpected message, disconnecting", _id) 426 writer.close() 427 return 428 # }}} 429 msg_handshake1 = msg.value 430 # Validate Handshake message {{{ 431 cookie_their = msg_handshake1["cookieResponder"] 432 pub_their_raw = msg_handshake1["pubKeyResponder"] 433 pub_their = gost3410.pub_unmarshal(bytes(pub_their_raw)) 434 ukm_raw = bytes(msg_handshake1["ukm"]) 435 ukm = ukm_unmarshal(ukm_raw) 436 key_session = kek_34102012256(CURVE, prv, pub_their, ukm, mode=2001) 437 kdf = Hkdf(None, key_session, hash=GOST34112012256) 438 key_handshake1_mac_identity = kdf.expand(b"handshake1-mac-identity") 439 key_handshake1_enc = kdf.expand(b"handshake1-enc") 440 key_handshake1_mac = kdf.expand(b"handshake1-mac")
UKM is a 64-bit number (urandom (8)), which also requires deserialization from the byte representation using gost3410_vko.ukm_unmarshal (). VKO function for 10/34/2012 256-bit is gost3410_vko.kek_34102012256 () (KEK - key encryption key).
The generated session key is already a 256-bit byte pseudo-random sequence. Therefore, it can be used immediately in the HKDF function. Since GOST34112012256 satisfies the hashlib interface, it can be used immediately in the Hkdf class. We do not indicate the salt (the first argument of Hkdf), since the key generated due to the ephemerality of the key pairs involved will be different for each session and there is already enough entropy in it. kdf.expand () by default already produces 256-bit keys, required for Grasshopper in the future.
Next, the TBE and TBS parts of the received message are checked:- вычисляется и проверяется MAC над пришедшим шифротекстом;
- дешифруется шифротекст;
- декодируется TBE структура;
- из неё берётся идентификатор собеседника и проверяется известен ли он нам вообще;
- вычисляется и проверятся MAC над этим идентификатором;
- проверяется подпись над TBS структурой, в которую входят cookie обеих сторон и публичный эфемерный ключ противоположной стороны. Подпись проверяется долгоживущим ключом подписи собеседника.
441 try: 442 peer_name = validate_tbe( 443 msg_handshake1, 444 key_handshake1_mac_identity, 445 key_handshake1_enc, 446 key_handshake1_mac, 447 cookie_our, 448 cookie_their, 449 pub_their_raw, 450 ) 451 except ValueError as err: 452 logging.warning("%s: %s, disconnecting", _id, err) 453 writer.close() 454 return 455 # }}} 128 def validate_tbe( 129 msg_handshake: Union[MsgHandshake1, MsgHandshake2], 130 key_mac_identity: bytes, 131 key_enc: bytes, 132 key_mac: bytes, 133 cookie_their: Cookie, 134 cookie_our: Cookie, 135 pub_key_our: PubKey, 136 ) -> str: 137 ciphertext = bytes(msg_handshake["ciphertext"]) 138 mac_tag = mac(GOST3412Kuznechik(key_mac).encrypt, KUZNECHIK_BLOCKSIZE, ciphertext) 139 if not compare_digest(mac_tag, bytes(msg_handshake["ciphertextMac"])): 140 raise ValueError("invalid MAC") 141 plaintext = ctr( 142 GOST3412Kuznechik(key_enc).encrypt, 143 KUZNECHIK_BLOCKSIZE, 144 ciphertext, 145 8 * b"\x00", 146 ) 147 try: 148 tbe, _ = HandshakeTBE().decode(plaintext) 149 except ASN1Error: 150 raise ValueError("can not decode TBE") 151 key_sign_pub_hash = bytes(tbe["identity"]) 152 peer = KEYS.get(key_sign_pub_hash) 153 if peer is None: 154 raise ValueError("unknown identity") 155 mac_tag = mac( 156 GOST3412Kuznechik(key_mac_identity).encrypt, 157 KUZNECHIK_BLOCKSIZE, 158 key_sign_pub_hash, 159 ) 160 if not compare_digest(mac_tag, bytes(tbe["identityMac"])): 161 raise ValueError("invalid identity MAC") 162 tbs = HandshakeTBS(( 163 ("cookieTheir", cookie_their), 164 ("cookieOur", cookie_our), 165 ("pubKeyOur", pub_key_our), 166 )) 167 if not gost3410.verify( 168 CURVE, 169 peer["pub"], 170 GOST34112012256(tbs.encode()).digest(), 171 bytes(tbe["signature"]), 172 ): 173 raise ValueError("invalid signature") 174 return peer["name"]
As already mentioned above, 34.13-2015 describes the various modes of operation of block ciphers from 34.12-2015. Among them there is a mode of generating an imitation insert, computing a MAC. In PyGOST, this is gost3413.mac (). This mode requires the transfer of the encryption function (receiving and returning one data block), the size of the cipher block and, in fact, the data itself. Why is it impossible to hardcode the size of the cipher block? 12/34/2015 describes not only the 128-bit Grasshopper cipher, but also the 64-bit Magma - a slightly modified GOST 28147-89, created back in the KGB and still having one of the highest security thresholds.
The grasshopper is initialized with a gost.3412.GOST3412Kuznechik (key) call and returns an object with .encrypt () /. Decrypt () methods suitable for passing to 34.13 functions. The MAC is computed as follows: gost3413.mac (GOST3412Kuznechik (key) .encrypt, KUZNECHIK_BLOCKSIZE, ciphertext). You cannot use normal comparison (==) of byte strings to compare the calculated and the received MAC, since this operation gives a comparison time leak, which, in general, can lead to fatal vulnerabilities like BEAST attacks on TLS. Python has a special hmac.compare_digest function for this.
The block cipher function can only encrypt one block of data. For a larger number, and even not a multiple length, it is necessary to use the encryption mode. The following are described in 34.13-2015: ECB, CTR, OFB, CBC, CFB. Each has its own acceptable scope and characteristics. Unfortunately, we still do not have standardized authenticated encryption modes (such as CCM, OCB, GCM and the like) - we are forced to at least add a MAC ourselves. I choose the counter mode (CTR): it does not require addition to the block size, it can be parallelized, it uses only the encryption function, it can be safely used to encrypt a large number of messages (unlike CBC, which collisions relatively quickly).
Like .mac (), .ctr () accepts similar input data: ciphertext = gost3413.ctr (GOST3412Kuznechik (key) .encrypt, KUZNECHIK_BLOCKSIZE, plaintext, iv). An initialization vector is required that is exactly half the length of the cipher block. If our encryption key is used only to encrypt a single message (even from several blocks), then it is safe to specify a zero initialization vector. To encrypt handshake messages, we use a separate key each time.
Signature verification gost3410.verify () is trivial: we pass an elliptic curve within which we work (we just fix it in our GOSTIM protocol), the public key of the signatory (do not forget that this should be a tuple of two large numbers, not a byte string), 11/34/2012 hash and the signature itself.
Further, in the initiator, we prepare and send handshake2 a handshake message, performing the same actions that we did when checking, only symmetrically: signing on our keys instead of checking, etc.456 # Prepare and send Handshake 2 message {{{ 457 tbs = HandshakeTBS(( 458 ("cookieTheir", cookie_their), 459 ("cookieOur", cookie_our), 460 ("pubKeyOur", pub_our_raw), 461 )) 462 signature = gost3410.sign( 463 CURVE, 464 KEY_OUR_SIGN_PRV, 465 GOST34112012256(tbs.encode()).digest(), 466 ) 467 key_handshake2_mac_identity = kdf.expand(b"handshake2-mac-identity") 468 mac_tag = mac( 469 GOST3412Kuznechik(key_handshake2_mac_identity).encrypt, 470 KUZNECHIK_BLOCKSIZE, 471 bytes(KEY_OUR_SIGN_PUB_HASH), 472 ) 473 tbe = HandshakeTBE(( 474 ("identity", KEY_OUR_SIGN_PUB_HASH), 475 ("signature", OctetString(signature)), 476 ("identityMac", MAC(mac_tag)), 477 )) 478 tbe_raw = tbe.encode() 479 key_handshake2_enc = kdf.expand(b"handshake2-enc") 480 key_handshake2_mac = kdf.expand(b"handshake2-mac") 481 ciphertext = ctr( 482 GOST3412Kuznechik(key_handshake2_enc).encrypt, 483 KUZNECHIK_BLOCKSIZE, 484 tbe_raw, 485 8 * b"\x00", 486 ) 487 mac_tag = mac( 488 GOST3412Kuznechik(key_handshake2_mac).encrypt, 489 KUZNECHIK_BLOCKSIZE, 490 ciphertext, 491 ) 492 writer.write(Msg(("handshake2", MsgHandshake2(( 493 ("ciphertext", OctetString(ciphertext)), 494 ("ciphertextMac", MAC(mac_tag)), 495 )))).encode()) 496 # }}} 497 await writer.drain() 498 logging.info("%s: session established: %s", _id, peer_name)
When the session is established, transport keys are generated (a separate key for encryption, for authentication, for each side), the Grasshopper is initialized to decrypt and verify the MAC:499 # Run text message sender, initialize transport decoder {{{ 500 key_initiator_enc = kdf.expand(b"transport-initiator-enc") 501 key_initiator_mac = kdf.expand(b"transport-initiator-mac") 502 key_responder_enc = kdf.expand(b"transport-responder-enc") 503 key_responder_mac = kdf.expand(b"transport-responder-mac") ... 509 asyncio.ensure_future(msg_sender( 510 peer_name, 511 key_initiator_enc, 512 key_initiator_mac, 513 writer, 514 )) 515 encrypter = GOST3412Kuznechik(key_responder_enc).encrypt 516 macer = GOST3412Kuznechik(key_responder_mac).encrypt 517 # }}} 519 nonce_expected = 0 520 # Wait for test messages {{{ 521 while True: 522 data = await reader.read(MaxMsgLen) ... 530 msg, tail = Msg().decode(buf) ... 537 try: 538 await msg_receiver( 539 msg.value, 540 nonce_expected, 541 macer, 542 encrypter, 543 peer_name, 544 ) 545 except ValueError as err: 546 logging.warning("%s: %s", err) 547 break 548 nonce_expected += 1 549 # }}}
msg_sender coroutine now encrypts messages before sending to a TCP connection. Each message has a monotonically increasing nonce, which is also the initialization vector for encryption in counter mode. Each message and message block is guaranteed to have different counter values.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
Incoming messages are processed by the msg_receiver authentication and decryption coroutine:async def msg_receiver( msg_text: MsgText, nonce_expected: int, macer, encrypter, peer_name: str, ) -> None: payload = msg_text["payload"] if int(payload["nonce"]) != nonce_expected: raise ValueError("unexpected nonce value") mac_tag = mac(macer, KUZNECHIK_BLOCKSIZE, payload.encode()) if not compare_digest(mac_tag, bytes(msg_text["payloadMac"])): raise ValueError("invalid MAC") plaintext = ctr( encrypter, KUZNECHIK_BLOCKSIZE, bytes(payload["ciphertext"]), long2bytes(nonce_expected, 8), ) text = plaintext.decode("utf-8") await OUT_QUEUES[peer_name].put(text)
Conclusion
GOSTIM is supposed to be used exclusively for educational purposes (since it is not covered by tests, at least)! The source code of the program can be downloaded here (Stribog-256 hash: 995bbd368c04e50a481d138c5fa2e43ec7c89bc77743ba8dbabee1fde45de120). Like all my projects, such as GoGOST , PyDERASN , NNCP , GoVPN , GOSTIM is completely free software distributed under the terms of GPLv3 + .
Sergey Matveev , cipher bank , member of the Open Society Foundation Foundation , Python / Go-developer, chief specialist of FSUE “STC Atlas” .