This document describes the formal behavior expected of I6P at the session/protocol level and proposes a freezable public API for consumption by applications.
Scope note: the current implementation covers the authenticated handshake (
HELLO) and the base infrastructure (QUIC/TLS, identity, codec, crypto, and transfer). Some message types listed are reserved for protocol evolution.
- Node: local entity (e.g., process) that has an identity and can
Listenand/orDial. - Peer: remote entity identified by
PeerID. - PeerID: stable identifier derived from the Ed25519 public key.
- Session: authenticated and multiplexed (QUIC) connection between two peers.
- Control stream: stream dedicated to control (handshake and control messages).
- Authenticate the remote peer via Ed25519 signature.
- Link
PeerIDto the presented public key (PeerID = SHA-256(PublicKey)). - Exchange capabilities (
map[string]string) for simple feature negotiation.
- Open a QUIC connection to the server address.
- Open a control stream (dedicated stream).
- Send a
Frame{Type: HELLO, Payload: EncodeHello(Hello)}. - Read the response
Framefrom the server. - Validate that
Type == HELLO. - Decode
Helloand verify:PeerIDis valid (hex) and matchesSHA-256(PublicKey).Signaturevalidates for the signature bytes defined below.
- Session is marked as ESTABLISHED.
- Accept QUIC connection.
- Accept control stream (opened by client).
- Read initial
Frame. - Validate that
Type == HELLO. - Decode and verify client
Hello. - Respond with signed server
HELLO. - Session is marked as ESTABLISHED.
- Authenticity: guaranteed by Ed25519.
- Identity binding: guaranteed by checking
PeerID == SHA-256(PublicKey). - Confidentiality: provided by QUIC/TLS 1.3 (transport layer). Optionally, an E2E layer can be used via
crypto.SecureChannel.
I6P uses binary frames in the control stream.
Format:
type: 1 bytepayload_len: 4 bytes big-endianpayload: N bytes
Limits:
payload_len <= 1 MiB
Values:
1 = HELLO(implemented and used in the handshake)2 = PEER_INFO(reserved)3 = DATA(reserved)4 = ACK(reserved)5 = CLOSE(reserved)
Important: currently, the handshake uses only
HELLOin the control stream. Application data transfer occurs in QUIC streams opened after the handshake.
Fields:
peer_id(string): hex ofPeerIDpublic_key(bytes): Ed25519 public keytimestamp_sec(int64)nonce(bytes): 32 random bytescapabilities(map[string]string, optional)signature(bytes): Ed25519 signature
Signed bytes (SigningBytes()):
PeerID(32 bytes)PublicKey(32 bytes)TimestampSec(uint64 big-endian)Nonce(32 bytes)Capabilitiesin deterministic order:- sort keys lexicographically
- for each pair (k,v): write
len(k)(uint16 BE) +k+len(v)(uint16 BE) +v
Verification (Verify()):
len(PublicKey) == 32PeerIDFromPublicKey(PublicKey) == PeerID(binary comparison)ed25519.Verify(PublicKey, SigningBytes(), Signature) == true
- NEW: instantiated, no listener.
- LISTENING: active QUIC listener.
- DIALING: connection attempt in progress.
- CLOSED: node closed, does not accept new connections.
Typical transitions:
NEW -> LISTENINGviaListen(...)LISTENING -> CLOSEDviaClose()NEW -> DIALING -> ESTABLISHED_SESSIONviaDial(...)
- HANDSHAKING: control stream created/accepted and
HELLOexchanged. - ESTABLISHED: peer identity verified.
- CLOSING/CLOSED: closure due to error/application code.
Invariants:
- Application streams should only be used after
ESTABLISHED. - The control stream is reserved and should not carry application data.
When used, crypto.SecureChannel has:
- NEW: ephemeral keys generated.
- ESTABLISHED:
Complete(peerPub)performed and ratchets initialized.
The idea of "freezable" is to provide a minimal, stable, and easy-to-version surface.
v1: changes should not break existing signatures.- Additions are allowed via:
- new methods on concrete types (not interfaces) and/or
- new optional interfaces
- Stable interfaces should be small and focused.
package i6p
import (
"context"
"io"
"net/netip"
"github.com/TheusHen/I6P/i6p/identity"
)
type PeerID = identity.PeerID
type PeerInfo struct {
ID PeerID
Addr netip.AddrPort
Capabilities map[string]string
}
// Stream is the smallest useful abstraction for application data.
// (Implementations can expose extras via type assertion.)
type Stream interface {
io.Reader
io.Writer
io.Closer
}
type Session interface {
LocalPeerID() PeerID
RemotePeerID() PeerID
RemoteCapabilities() map[string]string
OpenStream(ctx context.Context) (Stream, error)
AcceptStream(ctx context.Context) (Stream, error)
Close() error
CloseWithError(code uint64, msg string) error
}
type Node interface {
ID() PeerID
Capabilities() map[string]string
Listen(addr netip.AddrPort) error
ListenAddr() (netip.AddrPort, bool)
Close() error
Accept(ctx context.Context) (Session, error)
Dial(ctx context.Context, peer PeerInfo) (Session, error)
}- Today, the main entrypoint is
i6p.Peerwith:Listen(addr string) errorDial(ctx, addr string) (*session.Session, error)Accept(ctx) (*session.Session, error)
The above proposal standardizes netip.AddrPort and hides the QUIC stream type behind Stream.
- Handshake: signed and verified
HELLO - Frame: type + length + payload, 1 MiB limit
- States: Node/Session defined and explicit invariants
- Freezable API: small interfaces, focus on compatibility