Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/Project516/p2p-chat/llms.txt

Use this file to discover all available pages before exploring further.

p2p-chat is designed to be as simple as possible: two peers connect directly over TCP, exchange ephemeral encryption keys, and then send encrypted messages back and forth. There is no broker, relay, or server involved at any stage. This page describes how the pieces fit together — from the moment listen binds a port to the moment a message lands on screen.

High-level design

p2p-chat is a single-binary Go program. It supports exactly one connection at a time: the listener accepts one incoming TCP connection and then stops accepting. Both sides are symmetric once the session is established — each peer can send and receive freely.

Package layout

crypto/

Key exchange and per-message encryption/decryption using NaCl box (X25519 + XSalsa20-Poly1305).

internal/chat/

Session management: reading stdin, dispatching slash commands, and driving the message loop.

internal/transport/

Length-prefixed framing. Writes and reads wire frames — a 2-byte big-endian length header followed by the payload.

internal/version/

Reads assets/version.txt at runtime and returns the version string.
The top-level main.go parses the CLI arguments and delegates to either chat.Listen or chat.Connect. Both functions follow the same lifecycle once a TCP connection is established.

Connection lifecycle

1

Bind or dial

The host calls chat.Listen(address), which binds a TCP socket and calls Accept() to block until one peer connects. The connecting peer calls chat.Connect(address), which dials the given TCP address. The underlying transport is a plain TCP stream — there is no TLS handshake at this layer.
2

Key exchange

Immediately after the TCP connection is established, both sides call crypto.ExchangeKeys. Each peer generates an ephemeral X25519 key pair, sends its public key to the other side over the raw TCP connection, and reads the other peer’s public key in return. The shared NaCl box key is derived from these two public keys.No key material is written to disk. Keys exist only in memory for the duration of the session.
3

Bidirectional message loop

Once keys are exchanged, the session enters the message loop. Incoming messages are handled in a goroutine that reads frames from the TCP connection, decrypts them, and prints them to stdout. Outgoing messages are handled in the main goroutine, which reads lines from stdin, encrypts them, and writes frames to the TCP connection.
4

Session end

Either peer can terminate the session by typing /quit, which sends a leave notice frame before closing the connection. When the TCP connection closes (from either side), the goroutine reading incoming frames exits, and the process ends.

Transport layer: length-prefixed framing

All data on the wire passes through internal/transport, which implements a simple length-prefix framing protocol. Every frame consists of a 2-byte big-endian uint16 length header followed immediately by the payload bytes.
┌──────────────────┬──────────────────────────────┐
│  Length (2 bytes)│  Payload (up to 65535 bytes) │
│  uint16 big-end  │  encrypted ciphertext         │
└──────────────────┴──────────────────────────────┘
SendFrame writes a frame:
func SendFrame(w io.Writer, data []byte) error {
    length := uint16(len(data))
    lenBuf := make([]byte, 2)
    binary.BigEndian.PutUint16(lenBuf, length)
    _, err := w.Write(lenBuf)
    // ...
    _, err = w.Write(data)
    // ...
    return nil
}
ReceiveFrame reads a frame:
func ReceiveFrame(r io.Reader) ([]byte, error) {
    var lenBuf [2]byte
    _, err := io.ReadFull(r, lenBuf[:])
    // ...
    length := binary.BigEndian.Uint16(lenBuf[:])
    encrypted := make([]byte, length)
    _, err = io.ReadFull(r, encrypted)
    // ...
    return encrypted, nil
}
The maximum payload size is 65,535 bytes — the largest value a uint16 can represent. Messages longer than this cannot be sent.

Crypto layer

Encryption is handled by crypto/crypto.go, which wraps the NaCl box primitive.
  • ExchangeKeys — exchanges public keys over the open TCP connection and returns a shared 32-byte key derived via X25519 Diffie-Hellman.
  • Encrypt(plaintext, sharedKey) — generates a random 24-byte nonce, encrypts the plaintext with XSalsa20-Poly1305, and returns nonce || ciphertext.
  • Decrypt(encrypted, sharedKey) — splits the first 24 bytes as the nonce, decrypts the remainder, and returns the plaintext.

Wire message format

After framing is stripped, each payload has the following internal layout:
┌─────────────────────────┬──────────────────────────────┐
│  Nonce (24 bytes)        │  Ciphertext (variable)       │
│  random per message      │  XSalsa20-Poly1305 output    │
└─────────────────────────┴──────────────────────────────┘
The full byte sequence on the wire for a single message is therefore:
[uint16 length] [24-byte nonce] [ciphertext]
The uint16 length covers the nonce and ciphertext together.

Concurrency model

p2p-chat uses two goroutines per session:
GoroutineResponsibility
Main goroutineReads lines from stdin, processes slash commands, encrypts and writes outgoing frames
Receiver goroutineReads incoming frames from the TCP connection, decrypts them, and prints to stdout
The two goroutines share the TCP connection object but operate on opposite directions of it (write vs. read), so no mutex is needed on the connection itself. Stdout access is not explicitly synchronized, which means output from the receiver goroutine may occasionally interleave with the user’s typed input — this is an accepted trade-off of the minimal design.

Version

The version string is stored in assets/version.txt and read at runtime by internal/version/version.go:
func ReadVersion() string {
    content, err := os.ReadFile("assets/version.txt")
    // ...
    return string(content)
}
The current version is 1.0.0. Both the go run . version subcommand and the /version slash command call this function. The file must be present at the working directory from which you run the program.

Build docs developers (and LLMs) love