PeerNet.Channel (PeerNet v0.1.0)

Copy Markdown View Source

Post-handshake transport: ChaCha20-Poly1305 AEAD over the same Frame layer used during the handshake.

Each side of a connection holds two CipherState structs:

  • tx — for outbound application messages
  • rx — for inbound application messages

Initiator's tx is the responder's rx and vice-versa, so each direction has its own key and independent nonce counter (see Noise spec §5.2 "Split").

Wire shape per message

Frame(<<ciphertext::binary, tag::binary-size(16)>>)
  • Frame is the same length-prefixed wrapper used during handshake, so the read loop in PeerNet.Connection doesn't need to switch framing modes when transitioning from handshake to active.
  • ciphertext is the ChaCha20-Poly1305 ciphertext of the :erlang.term_to_binary/1 ETF of the application envelope (:call, :reply, :send, :ping, :pong, etc).
  • tag is the 16-byte Poly1305 authentication tag.

AAD is empty by design — this channel doesn't bind the encrypted payload to any header beyond the implicit ordering provided by the nonce counter. Replay across connections is impossible because the key is derived from the per-session ephemeral DH; replay within a connection is impossible because each AEAD failure aborts the link.

Nonce management

Per Noise spec §5.1 nonce format: 4 zero bytes followed by the 8-byte little-endian counter. Counter starts at zero and increments monotonically. PeerNet aborts the connection if the counter reaches 2^64 - 1 (an attacker would have to send ~10^19 messages to trigger this — any actual occurrence is a bug, not a real attack).

Summary

Functions

Decrypt one frame body. Returns

Encrypt term and return {frame_bytes, new_cipher_state}.

Functions

decrypt(cs, frame_body)

@spec decrypt(PeerNet.Channel.CipherState.t(), binary()) ::
  {:ok, term(), PeerNet.Channel.CipherState.t()}
  | {:error, :bad_decrypt | :invalid_term | :counter_exhausted,
     PeerNet.Channel.CipherState.t()}

Decrypt one frame body. Returns:

  • {:ok, term, new_cipher_state} — successful decode + AEAD verify.
  • {:error, :bad_decrypt, cipher_state} — auth failed (wire tampering or key/nonce mismatch). Caller should close the link.
  • {:error, :invalid_term, cipher_state} — AEAD ok but the decrypted bytes don't safely deserialise to a term. Caller should close the link (a peer producing valid AEAD with garbage payload is misbehaving).
  • {:error, :counter_exhausted, cipher_state} — nonce wrap.

encrypt(cs, term)

@spec encrypt(PeerNet.Channel.CipherState.t(), term()) ::
  {binary(), PeerNet.Channel.CipherState.t()}
  | {:error, :counter_exhausted, PeerNet.Channel.CipherState.t()}

Encrypt term and return {frame_bytes, new_cipher_state}.

The result is a complete length-prefixed frame ready to be written to the socket. Caller updates its handshake/connection state with the returned cipher state.

Returns {:error, :counter_exhausted, cipher_state} if the cipher has hit its nonce limit. PeerNet treats this as connection-fatal.