Skip to content

Security

phgm-d3ab edited this page Jan 3, 2023 · 5 revisions

Overview

This page assumes reader's familiarity with Noise Protocol Framework, uses its terminology and does not go into details. It is very helpful to confirm that assumption (and it is a good read in any case).

Security of this protocol consists of a simple request-response mechanism which allows both parties to authenticate each other and to establish two encryption keys for current session. Once the keys are ready, they are used to encrypt transport messages sent by peers.

Specific details are covered in Noise Protocol Framework documentation. Nmp uses Noise_IK_448_ChaChaPoly_BLAKE2b handshake pattern of that specification which means usage of following cryptographic algorithms:

  • Curve448
  • ChaCha20-Poly1305
  • BLAKE2b

Handshake

Protocol uses IK pattern, meaning that initiating party must know public key of the responder. Initiator's public key is transferred with the first message which allows responder to make a decision whether to allow this session or not.

Pattern itself looks as follows:

IK:
      <- s
      ...
      -> e, es, s, ss
      <- e, ee, se

Data in both handshake packets is protected, specific properties of this are discussed in Noise spec.

There are two more things worth mentioning here:

  • nmp slightly deviates from Noise spec to allow authentication of its own header: by mix_hash()ing its data (packet type, pad & session id)
  • both request and response have additional payload (which Noise allows) to enable some flexibility for application

Request

This is the first packet sent during connection establishment. It is defined as follows:

struct request {
        u8 packet_type;
        u8 pad[3];
        u32 session_id;
        u8 ephemeral_public[56];
        u8 encrypted_static[56];
        u8 mac1[16];
        u8 encrypted_payload[128];
        u8 mac2[16];
};

Encrypted payload has a timestamp in milliseconds of International Atomic Time, 24 bytes reserved for future use and 96 bytes of application defined payload:

struct encrypted_payload {
        u64_le timestamp;
        u8 reserved[24];
        u8 app_data[96];
};

Application payload is encapsulated and never accessed or modified by nmp. It is presented to responder if initiation packet passes validation: if it is not expired and cryptography checks out.

Request is composed as per Noise processing rules, token by token with the exception of one more mix_hash():

h = protocol_name /* zero padded */
h = hash(h || rs) 

type = 0
pad = {0,0,0}
header = type || pad || session_id

h = hash(h || header)
...

Response

The second packet: it is sent by responder's choice. That is: if responder's application layer wishes to authenticate received request and proceed with the connection. Note: current library implementation also allows sending response without establishing a session (for instance to optionally send a rejection notice).

Response is defined as follows:

struct response {
        u8 packet_type;
        u8 pad[3];
        u32 session_id;
        u8 ephemeral_public[56];
        u8 encrypted_payload[128];
        u8 mac[16];
};

This packet is constructed similarly to previous one: we simply follow Noise processing rules with the exception of extra mix_hash() for header.

As for encrypted payload, response does not need to have a timestamp included so this field is left zeroed.

Transport

Once encryption keys are established, both parties use their own key for encryption and are now able to send encrypted data messages.

When initiator receives a valid response, it is initiator's duty to send a first data packet (which can be empty).

Definition of transport packet:

struct data {
        u8 type;
        u8 pad[3];
        u32 session_id;
        u64_le counter;
        u8 encrypted_payload[...];
        u8 mac[16];
};

Having separate encryption keys for both sides of a connection eliminates a seemingly difficult problem of tracking nonces. As it is required that they are unique, having a key that is used only for encryption makes it as easy as simply incrementing a counter on each encryption operation. This counter, a nonce, starts at zero and increments up to (2^64) - 1. If this value is reached, session is considered expired and no more data can be sent.

Payload is zero padded, so it's length is a multiple of 16.

Transport packet is composed as follows (using encrypt() from Noise):

header = type || pad || session_id || counter
(ciphertext, mac) = encrypt(key, counter, header, plaintext)

packet = header || ciphertext || mac

Decryption is done in similar fashion. Data inside payload is processed only if decryption succeeds.

Clone this wiki locally