Mutual-authentication handshake for PeerNet — implementation of the Noise
protocol's XX pattern with the suite
Noise_XX_25519_ChaChaPoly_SHA256.
Why Noise XX
The XX pattern is the canonical "two-party mutual auth without pre-shared knowledge of public keys" Noise pattern. It gives us:
- Forward secrecy — every session uses fresh ephemeral X25519 keys. Compromising the long-term static key does not reveal past sessions.
- Mutual authentication — both parties' static keys are cryptographically bound into the transcript hash; a peer cannot impersonate another even if they know the other's public key.
- Identity hiding — static keys travel encrypted under the ephemeral DH; a passive observer cannot link a session to a long-term identity from the wire alone.
- Replay resistance — every session has a fresh transcript hash.
Wire shape
Three messages over the framed wire (PeerNet.Frame):
M1 (initiator → responder)
e
M2 (responder → initiator)
e, ee, s, es # peer ephemeral, then encrypted static + payload
M3 (initiator → responder)
s, se # encrypted static + payloadEach "encrypted" piece is wrapped with ChaCha20-Poly1305 using the key
derived from successive MixKey operations on the running chain. The
full Noise message-handling sequence is implemented in step/2.
After the handshake
Once both sides reach :authenticated, the state contains two
PeerNet.Channel.CipherState instances:
tx_state— used to encrypt outbound application messagesrx_state— used to decrypt inbound application messages
Initiator's tx is the responder's rx and vice versa, so messages
in each direction use independent keys and nonce counters.
Trust check
The peer's static public key is checked against the trust set at the
point it's revealed during the handshake. A peer not in the trust set
causes the handshake to abort with {:error, :untrusted_peer, role}.
This happens after Noise has cryptographically verified the peer
actually knows the corresponding private key, so a forged static key
is impossible.
Examples
iex> a = PeerNet.Identity.generate()
iex> state = PeerNet.Handshake.init(:initiator, a, MapSet.new())
iex> state.role
:initiator
iex> state.phase
:send_m1
Summary
Types
@type phase() ::
:send_m1
| :wait_m1
| :send_m2
| :wait_m2
| :send_m3
| :wait_m3
| :authenticated
@type t() :: %PeerNet.Handshake{ ck: binary(), eph_priv: binary() | nil, eph_pub: binary() | nil, h: binary(), identity: PeerNet.Identity.t(), inbox: binary(), k: binary() | nil, n: non_neg_integer(), peer_eph: binary() | nil, peer_pubkey: binary() | nil, phase: phase(), role: :initiator | :responder, rx: PeerNet.Channel.CipherState.t() | nil, trust: MapSet.t(binary()), tx: PeerNet.Channel.CipherState.t() | nil }
Handshake state for one side of the exchange.
Functions
@spec init(:initiator | :responder, PeerNet.Identity.t(), MapSet.t(binary())) :: t()
Initialise a handshake state for one side.
role—:initiator(the dialer) or:responder(the acceptor).identity— this node'sPeerNet.Identity(X25519 keypair).trust—MapSetof peer public keys this node will accept.
Drive the state machine one step.
Pass inbound_bytes (default empty) — bytes received from the peer
since the last call. Returns:
{:ok, new_state, outbound_bytes}— possibly empty bytes to send to the peer, plus updated state.{:error, reason, role}— handshake failed; close the connection.
Reasons:
:untrusted_peer— peer's static key not in the trust list.:bad_decrypt— AEAD authentication failed (wire tampering).:malformed— wire bytes don't match the expected XX shape.:stalled— state machine asked to step in an unexpected phase.