Regarding `gemini://` over NaCL (replacing TLS)

Ciprian Dorin Craciun ciprian.craciun at gmail.com
Sun Mar 1 01:31:47 GMT 2020


So I've taken Sean Conner advice and implemented a proof-of-concept
client and server (only the protocol, transport and crypto part, not
the actual file serving) in Python by replacing TLS with NaCL /
`libsodium`.



The code is available on GitHub:

    https://github.com/cipriancraciun/gemini-experiments/blob/e4bbeae01a8e7d2e393ab93317890f5c7f511b09/nacl/sources

The sources are structured as such:

* `protocol_v1.py` builds upon `transport.py`, and actually implements
the `gemini://` protocol;  no surprises here, the `protocol_client`
function (that takes the server's address, an optional public key
(used for NaCL signatures), and a selector) interacts with the server
and returns the body;  similarly the `protocol_server` function takes
a listening socket and a handler function (that given a selector
returns the status, meta and body) and interacts with exactly one
client;

* `transport.py` builds upon `crypto.py` and `packets.py`;  more on this later;

* `crypto.py` wraps various NaCL functions and gives a better idea of
what happens;  again more on this later;

* `packets.py` basically just handles sockets and reading / writing
full payloads;  it also adds support for framed send / receive by
writing a 4 bytes length, followed by a payload of that many bytes (a
well known pattern);  this basically replaces TLS segments and removes
the need of line splitting, as now each individual protocol item
(selector, status and meta, body, and the key exchanges) uses one
single frame;

* the code is not "pythonic" (whatever that means), uses no global
state, and relies exclusively on functions that take all required
inputs as arguments;  (this way the functionality of one function can
be assessed only in terms of its inputs and outputs, thus I hope
simplifying the understanding;)  (one can think of it as almost purely
functional, with the exception of nonces and sockets which are both
mutated by successive calls to the various transport functions;)



Now regarding the transport / crypto and how it replaces TLS:

* as said, the transport module uses exclusively the framed (4 byte
length prefixed) packets, so from now on "send" and "receive" implies
such frames;

* the bulk of NaCL implementation happens inside `transport_prepare`
(https://github.com/cipriancraciun/gemini-experiments/blob/e4bbeae01a8e7d2e393ab93317890f5c7f511b09/nacl/sources/transport.py#L40-L94)
which does as follows;

* both the client and server use this function, thus from now on the
transport protocol is identical, thus symmetrical;

* by using `crypto_kx_keypair`
(https://libsodium.gitbook.io/doc/key_exchange) the local peer creates
an ephemeral session public / private key pair, which it exchanges
with its remote peer;  (this is the only piece of information that
doesn't travel encrypted, and thus available to an attacker;)

* the by using `crypto_kx_client_session_keys` (from the same link as
above) the local peer creates two symmetric secret keys, one for
sending and one for receiving;  (by now the remote peer has done the
same and obtained exactly the same symmetric secret keys;)

* also the local peer, using its own session public key (known to the
remote peer, and also known by an attacker) it creates (in a
deterministic manner, thus again known by an attacker) a nonce for
received packets;  using its remote peer session public key (again
known to an attacker) it creates a nonce for sent packets;  each time
one of these nonces are used (for sending and receiving), its
respective value is incremented by 1;  (according to the cryptographic
properties described at that page, having known nonces to the attacker
is OK, the only requirement is not to reuse them;)

* from here on, all exchanged messages are encrypted by using
`crypto_secretbox`
(https://libsodium.gitbook.io/doc/secret-key_cryptography/secretbox)
which provides both encryption and authentication against tampering;
(and coupled with the unique incremental nonces, it also assures that
the messages haven't been replayed, reordered, or dropped;)

* by using `crypto_sign_keypair`
(https://libsodium.gitbook.io/doc/public-key_cryptography/public-key_signatures)
the local peer creates a signature public / private key pair used for
authentication;  (both the client and server can use a stored public
key, or generated on the fly if `None` is used);

* by using `crypto_sign_detached` (the same link), the local peer
signs with its own signature private key the remote peer's session
public key;  the local peer then exchanges with the remote peer its
own signature public key and the computed signature;  (this proves
that the local peer has control over the signature secret key, and
also that this is not a replay attack as it signs something under the
control of the remote peer;)

* then, after receiving the remote peer's signature public key and the
detached signature it verifies it accordingly;

* at this moment the session is considered open, and the peers have
authenticated themselves to one-another;



A few notes about this encryption scheme:

* I am not a cryptographer or security expert, however I think I've
followed the best practices, especially those described in
(https://libsodium.gitbook.io/doc/secret-key_cryptography/encrypted-messages);
 (I bet an experimented cryptographer can take a look at this and say
if there are any issues, and how to fix those;)

* at the moment there is no padding, and give the way NaCL encrypts
data (i.e. the output has the same length as the input plus a fixed
constant), the length of the selector and header can be determined by
an attacker by looking at the network traffic;  adding a padding to
all packets multiple of say 128 bytes, and perhaps randomly adding one
to four such 128 bytes groups, would make these length unguessable
while impacting very little the network performance;

* at the moment two consecutive packets (like for example the
signature public key and signature verifier) are sent as different
`socket.send` calls, and thus perhaps as two different actual TCP
segments;  but a more efficient implementation can coalesce these into
a single `socket.send`;

* the overhead of the proposed protocol is minimal, both in terms of
payload size and latency due to round-trips (remember the protocol is
symmetric, thus this applies both to the client and server):
  * 32+4 bytes for the session public key exchange;
  * (both peers must read what was sent above before continuing, thus
one half-trip);
  * 32+16+4 bytes for the signature public key exchange;
  * 64+16+4 bytes for the signature verifier;
  * (both peers must read what was sent above before continuing, thus
another half-trip);
  * (so far a single complete round-trip was made;)
  * for each additional message an extra 16+4 bytes are sent;  (16
bytes for the `secretbox` authentication, and the 4 bytes for the
framing;)

* this schema is compliant with TCP FastOpen
(https://en.wikipedia.org/wiki/TCP_Fast_Open), i.e. the session public
key exchange can be done at the same time as the TCP
three-way-handshake happens;

* given the 4 bytes framing, the selector, headers and body can't be
larger than 4 GiB;  this is not a limitation, as we can implement for
the body an 8 bytes length prefixed framing;

* also given the 4 bytes framing, we solve the `Content-Length` issue,
and also the possibility of keep-alive;

* another advantage of the 4 bytes framing is that we can reuse the
frames instead of parsing for `CRLF`;

* at the moment TLS-SNI like functionality can be easily implemented
by asking the client to send with its signature public key also the
"virtual host" it wants to communicate with, thus based on that the
server can respond with that proper public key (this doesn't affect
the packet exchanges);  as opposed to TLS-SNI feature, this proposal
does not expose to an attacker the identity of the server public key
(i.e. the virtual host);  (granted there is a proposed extension to
TLS-SNI that encrypts that information;)

* the protocol should definitively require a version format (both for
the transport but also for the `gemini://` semantic) that should be
sent first by the client, and based on that the server should choose
the proper implementation if supported;  (however such a feature has
to be carefully implemented as not to be used by attackers to
downgrade a certain client into a vulnerable version;)



I hope I haven't made too many mistakes, and I hope this is useful as
a proof-of-concept that one could replace TLS for such simpler
protocols,
Ciprian.


More information about the Gemini mailing list