NTCP2 implementation details - Blog

I2P's transport protocols were originally developed around 15 years ago. Back
then, the main goal was to hide the transferred data, not to hide the fact that
one was using the protocol itself. Nobody thought seriously about protecting
against DPI (deep packets inspection) and protocols censorship. Times change,
and even though original transport protocols are still providing strong security,
there was a demand for a new transport protocol. NTCP2 is designed to resist
current censorship threats. Mainly, DPI analysis of packets length. Plus, the new
protocol uses the most modern cryptography developments. NTCP2 is based on the
Noise Protocol Framework, with SHA256
as a hash function and x25519 as an elliptic curve Diffie-Hellman (DH) key
exchange.

New crypto

NTCP2 requires adding the next cryptographic algorithms to an I2P implementation:

x25519

HMAC-SHA256

Chacha20

Poly1305

AEAD

SipHash

Compared to our original protocol, NTCP, NTCP2 uses x25519 instead of ElGamal
for DH function, AEAD/Chaha20/Poly1305 instead of AES-256-CBC/Adler32, and uses
SipHash for obfuscating the packet's length information. The key derivation
function used in NTCP2 is more complex, now using many HMAC-SHA256 calls.

i2pd (C++) implementation note: All of the algorithms mentioned above, except
SipHash, are implemented in OpenSSL 1.1.0. SipHash will be added to the coming
OpenSSL 1.1.1 release. For compatibility with OpenSSL 1.0.2, which is used in
most of the current systems, core i2pd developer
Jeff Becker has contributed standalone
implementations of missing cryptographic algorithms.

RouterInfo changes

NTCP2 requires having a third (x25519) key in addition to existing two (the
encryption and signature keys). It is called a static key and it has to be added
to any of RouterInfo addresses as an "s" parameter. It is required for both
NTCP2 initiator (Alice) and responder (Bob). If more than one address supports
NTCP2, for example, IPv4 and IPv6, "s" is required to be the same for all of
them. Alice's address is allowed to have just the "s" parameter without "host"
and "port" set. Also, a "v" parameter is required, that is currently always set
to "2".

NTCP2 address can be declared as a separate NTCP2 address or as an old-style
NTCP address with additional parameters, in which case it will accept both
NTCP and NTCP2 connections. Java I2P implementation uses the second approach,
i2pd (C++ implementation) uses the first.

If a node accepts NTCP2 connections, it has to publish its RouterInfo with the
"i" parameter, which is used as an initialization vector (IV) for the public
encryption key when that node establishes new connections.

Establishing a connection

To establish a connection both sides need to generate pairs of ephemeral x25519
keys. Based on those keys and "static" keys they derive a set of keys for data
transferring. Both parties must verify that the other side actually has a
private key for that static key, and that static key is the same as in RouterInfo.

A common x25519 key, called «input key material», is computed for each message,
after which message encryption key is generated with a MixKey function. A value
ck (chaining key) is kept while messages are being exchanged.
That value is used as a final input when generating keys for data transferring.

MixKey function looks something like this in the C++ implementation of I2P:

SessionRequest message is made of a public x25519 Alice key (32 bytes), a
block of data encrypted with AEAD/Chacha20/Poly1305 (16 bytes), a hash
(16 bytes) and some random data in the end (padding). Padding length is
defined in the encrypted block of data. Encrypted block also contains length of
the second part of the SessionConfirmed message. A block of data is
encrypted and signed with a key derived from Alice's ephemeral key and
Bob's static key. Initial ck value for MixKey function is set to SHA256
(Noise_XKaesobfse+hs2+hs3_25519_ChaChaPoly_SHA256).

Since 32 bytes of public x25519 key can be detected by DPI, it is encrypted with
AES-256-CBC algorithm using hash of Bob's address as a key and "i" parameter
from RouterInfo as an initialization vector (IV).

SessionCreated message has the same structure as SessionRequest, except
the key is computed based on ephemeral keys of both sides. IV generated after
encrypting/decrypting public key from SessionRequest message is used as IV
for encrypting/decrypting ephemeral public key.

SessionConfirmed message has 2 parts: public static key and Alice's
RouterInfo. The difference from previous messages is that ephemeral public key
is encrypted with AEAD/Chaha20/Poly1305 using the same key as SessionCreated.
It leads to increasing first part of the message from 32 to 48 bytes.
The second part is also encrypted with AEAD/Chaha20/Poly1305, but using a new
key, computed from Bob's ephemeral key and Alice's static key. RouterInfo part
can also be appended with random data padding, but it is not required, since
RouterInfo usually has various length.

Generation of data transfer keys

If every hash and key verification has succeeded, a common ck value must be
present after the last MixKey operation on both sides. This value is used to
generate two sets of keys <k, sipk, sipiv> for each side of a connection. "k" is
a AEAD/Chaha20/Poly1305 key, "sipk" is a SipHash key, "sipiv" is an initial
value for SipHash IV, that is changed after each use.

Code used to generate keys looks like this in the C++ implementation of I2P:

i2pd (C++) implementation note: First 16 bytes of the "sipkeys" array are a
SipHash key, the last 8 bytes are IV. SipHash requires two 8 byte keys, but i2pd
handles them as a single 16 bytes key.

Data transferring

Data is transferred in frames, each frame has 3 parts:

2 bytes of frame length obfuscated with SipHash

data encrypted with Chacha20

16 bytes of Poly1305 hash value

Maximum length of data transferred in one frame is 65519 bytes.

Message length is obfuscated by applying the XOR function with two first bytes
of the current SipHash IV.

Encrypted data part contains blocks of data. Each block is prepended with 3
bytes header, that defines block type and block length. Generally, I2NP type
blocks are transferred, that are I2NP messages with an altered header. One NTCP2
frame can transfer multiple I2NP blocks.

The other important data block type is a random data block. It is recommended to
add a random data block to every NTCP2 frame. Only one random data block can be
added and it must be the last block.

Those are other data blocks used in the current NTCP2 implementation:

RouterInfo — usually contains Bob's RouterInfo after the connection has been established, but it can also contain RouterInfo of a random node for the purpose of speeding up floodfills (there is a flags field for that case).

Termination — is used when a host explicitly terminates a connection and specifies a reason for that.

DateTime — a current time in seconds.

Summary

The new I2P transport protocol NTCP2 provides effective resistance against DPI
censorship. It also results in reduced CPU load because of the faster, modern
cryptography used. It makes I2P more likely to run on low-end devices, such as
smartphones and home routers. Both major I2P implementations have full support
for NTCP2 and it make NTCP2 available for use starting with version
0.9.36 (Java) and 2.20 (i2pd, C++).