A UDP-based transport protocol with an opinionated approach, similar to QUIC but focused on reasonable defaults over configurability. Goals: lower complexity, simplicity, security, and reasonable performance.
QOTP is P2P-friendly, supporting UDP hole punching, multi-homing (packets from different source addresses), out-of-band key exchange, no TIME_WAIT state, and single socket for multiple connections.
The following link shows example usage. Here is the most basic example that echoes back whatever it receives.
package main
import (
"context"
"io"
"log"
"your/path/qotp"
)
func main() {
// Create listener on random port
listener, err := qotp.Listen()
if err != nil {
log.Fatal(err)
}
defer listener.Close()
log.Println("Server started")
// Run event loop
ctx := context.Background()
listener.Loop(ctx, func(ctx context.Context, stream *qotp.Stream) error {
if stream == nil {
return nil // No data yet
}
data, err := stream.Read()
if err == io.EOF {
return nil // Stream closed
}
if err != nil {
return err
}
if len(data) > 0 {
stream.Write(data) // Echo back
}
return nil
})
}- Single crypto suite: curve25519/chacha20poly1305
- Always encrypted: No plaintext option
- In-band key rotation: Forward secrecy preserved via periodic ECDH rekeying
- 0-RTT option: User chooses between 0-RTT (no perfect forward secrecy) or 1-RTT (with perfect forward secrecy)
- BBR congestion control: Estimates network capacity via bottleneck bandwidth and RTT
- Connection-level flow control: Congestion control at connection level, not per-stream
- Simple teardown: FIN/ACK with timeout
- Compact: Goal < 3k LoC (currently ~2.8k LoC source)
In QOTP, there is 1 supported crypto algorithm (curve25519/chacha20-poly1305) as in contrast to TLS with many options. It is mentioned here that there are 60 RFCs for TLS. However, the Wikipedia site only mentions 9 primary RFCs and 48 extensions and informational RFCs, totalling 57 RFC.
- https://github.com/Tribler/utp4j
- https://github.com/quic-go/quic-go
- https://github.com/skywind3000/kcp (no encryption)
- https://github.com/johnsonjh/gfcp (golang version)
- https://eprints.ost.ch/id/eprint/846/
- https://eprints.ost.ch/id/eprint/879/ (https://github.com/stalder-n/lrp2p-go)
- https://eprints.ost.ch/id/eprint/979/
- Max RTT: Up to 30 seconds connection timeout (no hard RTT limit, but suspicious RTT > 30s logged)
- Packet identification: Stream offset (24 or 48-bit) + length (16-bit)
- Max Payload:
interfaceMTU - 48(typically 1452 for Ethernet; configurable viaWithMaxPayload)- Connections start at
conservativeMTU(1232) and negotiate up viapktMtuUpdate
- Connections start at
- Buffer capacity: 16MB send + 16MB receive (configurable constants)
- Crypto sequence space: 48-bit sequence number + 47-bit epoch = 2^95 total space
- Separate from transport layer stream offsets
- Rollover at 2^48 packets (not bytes) increments epoch counter
- At 2^95 exhaustion: ~5 billion ZB sent, requires manual reconnection
- Transport sequence space: 48-bit stream offsets per stream
- Multiple independent streams per connection
Flow 1: In-band Key Exchange (No Prior Keys)
Sender → Receiver: InitSnd (unencrypted, maxPayload bytes min)
- pubKeyEpSnd + (pubKeyIdSnd)
- Padded to prevent amplification
Receiver → Sender: InitRcv (encrypted with ECDH)
- pubKeyEpRcv + (pubKeyIdRcv)
- Can contain payload (perfect forward secrecy)
Both: Data messages (encrypted with shared secret)
Flow 2: Out-of-band Keys (0-RTT)
Sender → Receiver: InitCryptoSnd (encrypted - [prvKeyEpSnd + pubKeyIdRcv], non-PFS)
- pubKeyEpSnd + (pubKeyIdSnd)
- Can contain payload
- maxPayload bytes min with padding
Receiver → Sender: InitCryptoRcv (encrypted - [pubKeyEpSnd + prvKeyEpRcv], PFS)
- pubKeyEpRcv
- Can contain payload
Both: Data messages (encrypted with PFS shared secret)
Bits 0-4: Version (5 bits, currently 0)
Bits 5-7: Message Type (3 bits)
Message Types:
000(0): InitSnd - Initial handshake from sender001(1): InitRcv - Initial handshake reply from receiver010(2): InitCryptoSnd - Initial with crypto from sender011(3): InitCryptoRcv - Initial with crypto reply from receiver100(4): Data - All data messages101(5): Unused110(6): Unused111(7): Unused
CryptoVersion = 0
MacSize = 16 bytes (Poly1305)
SnSize = 6 bytes (48-bit sequence number)
MinProtoSize = 8 bytes (minimum payload)
PubKeySize = 32 bytes (X25519)
HeaderSize = 1 byte
ConnIdSize = 8 bytes
MsgInitFillLenSize = 2 bytes
MinInitRcvSizeHdr = 73 bytes (header + connId + 2 pubkeys)
MinInitCryptoSndSizeHdr = 65 bytes (header + 2 pubkeys)
MinInitCryptoRcvSizeHdr = 41 bytes (header + connId + pubkey)
MinDataSizeHdr = 9 bytes (header + connId)
FooterDataSize = 22 bytes (6 SN + 16 MAC)
MinPacketSize = 39 bytes (9 + 22 + 8)
Max Payload = interfaceMTU - 48 (typically 1452)
Conservative MTU = 1232 (IPv6 min link MTU 1280 - 48)
Send Buffer Capacity = 16 MB
Receive Buffer Capacity = 16 MB
Unencrypted, no data payload. Padded to maxPayload bytes to prevent amplification attacks.
Byte 0: Header (version=0, type=000)
Bytes 1-32: Public Key Ephemeral Sender (X25519)
First 8 bytes = Connection ID
Bytes 33-64: Public Key Identity Sender (X25519)
Bytes 65+: Padding to maxPayload bytes
Connection ID: First 64 bits of pubKeyEpSnd, used for the lifetime of the connection.
Encrypted with ECDH(prvKeyEpRcv, pubKeyEpSnd). Achieves perfect forward secrecy.
Byte 0: Header (version=0, type=001)
Bytes 1-8: Connection ID (from InitSnd)
Bytes 9-40: Public Key Ephemeral Receiver (X25519)
Bytes 41-72: Public Key Identity Receiver (X25519)
Bytes 73-78: Encrypted Sequence Number (48-bit)
Bytes 79+: Encrypted Payload (min 8 bytes)
Last 16: MAC (Poly1305)
Encrypted with ECDH(prvKeyEpSnd, pubKeyIdRcv). No perfect forward secrecy for first message.
Byte 0: Header (version=0, type=010)
Bytes 1-32: Public Key Ephemeral Sender (X25519)
First 8 bytes = Connection ID
Bytes 33-64: Public Key Identity Sender (X25519)
Bytes 65-70: Encrypted Sequence Number (48-bit)
Bytes 71-72: Filler Length (16-bit, encrypted)
Bytes 73+: Filler (variable, encrypted)
Bytes X+: Encrypted Payload (min 8 bytes)
Last 16: MAC (Poly1305)
Total: Padded to maxPayload bytes
Encrypted with ECDH(prvKeyEpRcv, pubKeyEpSnd). Achieves perfect forward secrecy.
Byte 0: Header (version=0, type=011)
Bytes 1-8: Connection ID (from InitCryptoSnd)
Bytes 9-40: Public Key Ephemeral Receiver (X25519)
Bytes 41-46: Encrypted Sequence Number (48-bit)
Bytes 47+: Encrypted Payload (min 8 bytes)
Last 16: MAC (Poly1305)
All subsequent data messages after handshake.
Byte 0: Header (version=0, type=100)
Bytes 1-8: Connection ID
Bytes 9-14: Encrypted Sequence Number (48-bit)
Bytes 15+: Encrypted Payload (min 8 bytes)
Last 16: MAC (Poly1305)
QOTP uses deterministic double encryption for sequence numbers and payload. A comparison with QUIC shows that QUIC uses a different approach called "header protection" where it samples 16 bytes from the encrypted payload, runs it through AES-ECB (or ChaCha20), and XORs the result with the packet number and header bits. This is a custom construction designed specifically for QUIC.
Note: The author is not a cryptographer. QOTP's approach was chosen for simplicity and reliance on standard primitives rather than custom constructions.
Encryption Process:
-
First Layer (Payload):
- Nonce: 12 bytes deterministic
- Bytes 0-5: Epoch (48-bit)
- Bytes 6-11: Sequence number (48-bit)
- Byte 0, bit 7 (MSB): 1=sender, 0=receiver (prevents nonce collision)
- Encrypt payload with ChaCha20-Poly1305
- AAD: header (unencrypted packet prefix)
- Output: ciphertext + 16-byte MAC
- Nonce: 12 bytes deterministic
-
Second Layer (Sequence Number):
- Nonce: First 24 bytes of first-layer ciphertext
- Encrypt sequence number (bytes 6-11 of deterministic nonce) with XChaCha20-Poly1305
- Take first 6 bytes only (discard MAC)
Decryption Process:
- Extract encrypted sequence number (first 6 bytes after header)
- Use first 24 bytes of ciphertext as nonce
- Decrypt 6-byte sequence number with XChaCha20 (no MAC verification)
- Reconstruct deterministic nonce with decrypted sequence number
- Try decryption with epochs: current, current-1, current+1
- Verify MAC on payload - any tampering fails authentication
Epoch Handling:
- Sequence number rolls over at 2^48 packets (not bytes)
- Epoch increments on rollover (47-bit; bit 7 of byte 0 reserved for direction)
- Decryption tries 3 epochs to handle reordering near boundaries
- Total space: 2^95 ≈ 40 ZB (exhaustion would require resending all human data 28M times)
QOTP supports in-band key rotation to maintain forward secrecy over long-lived connections. Both peers can initiate rotation independently.
Protocol Flags:
flagKeyUpdate(bit 5): Carries initiator's new ephemeral public key (32 bytes)flagKeyUpdateAck(bit 6): Carries responder's new ephemeral public key (32 bytes)
Key State: Each direction maintains three key slots:
prev: Previous key (for packets in transit during rotation)cur: Current active keynext: Pending key (computed but not yet promoted)
Rotation Flow:
Initiator Responder
| |
| KEY_UPDATE (new pubKeyEp) |
|--------------------------------->|
| | Generate new prvKeyEp
| | Compute next secret
| KEY_UPDATE_ACK (new pubKeyEp) |
|<---------------------------------|
| Compute next secret |
| Promote: prev=cur, cur=next |
| |
Decryption: Receiver tries cur, then prev, then next secrets to handle packets in flight during rotation.
Retransmission Handling: Duplicate KEY_UPDATE packets (same pubKey as current or previous round) are ignored or re-ACKed without generating new keys.
After decryption, payload contains transport header + data. Min 8 bytes total.
Byte 0 (Header byte):
Bit 0: hasAck
Bit 1: extend (48-bit offsets instead of 24-bit)
Bit 2: needsReTx
Bits 3-5: packet type (3 bits)
Bits 6-7: reserved
Packet Types (bits 3-5):
| Value | Name | Description |
|---|---|---|
| 0 | data | Plain data |
| 1 | mtuUpdate | MTU negotiation (2-byte value) |
| 2 | close | Stream close |
| 3 | keyUpdate | Key rotation initiation |
| 4 | keyUpdateAck | Key rotation acknowledgment |
| 5 | KU+KUAck | keyUpdate + keyUpdateAck |
| 6 | close+KU | close + keyUpdate |
| 7 | close+KUAck | close + keyUpdateAck |
MTU Negotiation (pktMtuUpdate):
- Carries a 2-byte
maxPayloadvalue from the sender - Included in init packets (InitCryptoSnd, InitRcv, InitCryptoRcv) and the first data packet after InitSnd handshake
- Mutually exclusive with close/keyUpdate flags
- Both peers exchange their
maxPayload; connection MTU =min(local, remote), floored atconservativeMTU(1232) - On consecutive packet losses (
mtuFallbackThreshold= 5), MTU falls back toconservativeMTU; restored on next successful ACK
Stream header (streamId + offset) is included when:
- Any non-data packet type, OR
- Has user data, OR
- No ACK (for minimum packet size)
ACK-only packets (userData == nil with hasAck): omit stream header to save space.
PING packets (userData == []byte{}): include stream header, used for keepalive/RTT measurement.
With ACK + Stream Data:
Byte 0: Header
Bytes 1-4: ACK Stream ID (32-bit) [if hasAck]
Bytes 5-7/10: ACK Offset (24 or 48-bit) [if hasAck]
Bytes 8-9/11-12: ACK Length (16-bit) [if hasAck]
Byte 10/13: ACK Receive Window (8-bit, encoded) [if hasAck]
Bytes X-X+1: MTU Value (16-bit) [if pktMtuUpdate]
Bytes Y-Y+31: Key Update Public Key (32 bytes) [if keyUpdate]
Bytes Z-Z+31: Key Update Ack Public Key (32 bytes) [if keyUpdateAck]
Bytes A-A+3: Stream ID (32-bit) [if hasStreamHeader]
Bytes A+4-A+6/9: Stream Offset (24 or 48-bit) [if hasStreamHeader]
Bytes A+7/10+: User Data
The 8-bit receive window field encodes buffer capacity from 0 to ~896GB using logarithmic encoding with 8 substeps per power of 2:
State Machine:
Startup → Drain/Normal → Probe → Normal
↓
Always: RTT inflation check
Pacing Gains:
- Startup: 277% (2.77x) - aggressive growth
- Normal: 100% (1.0x) - steady state
- Drain: 75% (0.75x) - reduce queue after startup
- Probe: 125% (1.25x) - periodic bandwidth probing
- DupAck: 90% (0.9x) - back off on duplicate ACK
State Transitions:
- Startup → Normal: When bandwidth stops growing (3 consecutive samples without increase)
- Normal → Drain: When RTT inflation > 150% of minimum
- Normal → DupAck: On duplicate ACK (reduce bandwidth to 98%)
- Normal → Probe: Every 8 × RTT_min (probe for more bandwidth)
Measurements:
SRTT = (7/8) × SRTT + (1/8) × RTT_sample
RTTVAR = (3/4) × RTTVAR + (1/4) × |SRTT - RTT_sample|
RTT_min = min(RTT_samples) over 10 seconds
BW_max = max(bytes_acked / RTT_min)
Pacing Calculation:
pacing_interval = (packet_size × 1e9) / (BW_max × gain_percent / 100)
If no bandwidth estimate: use SRTT / 10 or fallback to 10ms.
RTO = SRTT + 4 × RTTVAR
RTO = clamp(RTO, 100ms, 2000ms)
Default RTO = 200ms (when no SRTT)
Backoff: RTO_i = min(RTO × 2^(i-1), maxRTO) — capped at 2000ms per step
Max retries: 4 (total 5 attempts)
Example timing (default RTO = 200ms):
- Attempt 1: t=0 (200ms)
- Attempt 2: t=200ms (400ms)
- Attempt 3: t=600ms (800ms)
- Attempt 4: t=1400ms (1600ms)
- Attempt 5: t=3000ms (2000ms, capped)
- Fail: t=5000ms
Receive Window:
- Advertised in each ACK
- Calculated as:
buffer_capacity - current_buffer_usage - Encoded logarithmically (8-bit → 896GB range)
- Sender respects:
data_in_flight + packet_size ≤ rcv_window
Pacing:
- Sender tracks
next_write_time - Waits until
now ≥ next_write_timebefore sending - Even ACK-only packets respect pacing (can send early if needed)
Open → Active → Close_Requested → Closed (30s timeout)
Stream States:
Open: Normal read/write operationsCloseRequested: Close initiated, waiting for offset acknowledgmentClosed: All data up to close offset delivered, 30-second grace period
QOTP implements a clean bidirectional close mechanism similar to TCP FIN:
Close Initiation (calling Close()):
- Marks
closeAtOffsetin send buffer at current write position (queued data + pending) - Continues sending queued data normally
- When all data up to
closeAtOffsetis sent, sends CLOSE packet (may contain final data) sndClosedbecomes true when all data including CLOSE is ACKed
Receiving CLOSE:
- Receives CLOSE packet at offset X
- Marks
closeAtOffset = Xin receive buffer - Continues reading until reaching close offset
rcvClosedbecomes true whennextInOrder >= closeAtOffset
Stream Cleanup:
- Stream is fully closed when both
sndClosedandrcvClosedare true - Cleanup happens when closed AND no pending ACKs for that stream
Key Properties:
- Both directions close independently (half-close supported)
- CLOSE packets must be ACKed like regular data
- CLOSE can be combined with data (final data packet includes CLOSE flag)
- Empty CLOSE packets allowed when no data pending
Write()returnsio.EOFafterClose()is calledRead()returnsio.EOFafter receive direction is closed
Example Flow:
A writes 100 bytes → calls Close()
A.closeAtOffset = 100
A sends DATA[0-100] with CLOSE flag
B receives CLOSE at offset 100
B.closeAtOffset = 100
B sends ACK for [0-100]
B reads data, when nextInOrder reaches 100: B.rcvClosed = true
A receives ACK for [0-100]
No in-flight data, sent up to closeAtOffset: A.sndClosed = true
B calls Close()
B.closeAtOffset = 50 (whatever B's position is)
B sends CLOSE
A receives CLOSE at offset 50
A.closeAtOffset = 50
A reads to offset 50: A.rcvClosed = true
When both sides have sndClosed && rcvClosed: stream cleanup
Connection ID:
- First 64 bits of sender's ephemeral public key (
pubKeyEpSnd[0:8]) - Set once at connection creation, unchanged for connection lifetime
- Enables multi-homing (packets from different source addresses)
Connection Timeout:
- 30 seconds of inactivity (no packets received)
- Automatic cleanup after timeout
- Configured via
ReadDeadLineconstant
Single Socket:
- All connections share one UDP socket
- No TIME_WAIT state
- Scales to many short-lived connections
Send Buffer (SendBuffer):
- Capacity: 16 MB (configurable constant
sndBufferCapacity) - Tracks: queued data, in-flight data, ACKed data
- Per-stream accounting
queuedData: data waiting to be sent (not yet transmitted)inFlight: sent but not ACKed (LinkedMap keyed by offset+length)- Retransmission: oldest unACKed packet on RTO
Receive Buffer (ReceiveBuffer):
- Capacity: 16 MB (configurable constant
rcvBufferCapacity) - Handles: out-of-order delivery, overlapping segments
- Per-stream segments stored in LinkedMap (sorted by offset)
- Deduplication: checks against
nextInOrder - Overlap handling: validates matching data in overlaps (panics on mismatch)
- Tracks finished streams to reject data for cleaned-up streams
Packet Key Encoding (64-bit):
Bits 0-15: Length (16-bit)
Bits 16-63: Offset (48-bit)
Enables O(1) in-flight packet tracking and ACK processing.
Crypto Layer Overhead:
- InitSnd: maxPayload bytes (no data, padding)
- InitRcv: 103+ bytes (73 header + 6 SN + 16 MAC + ≥8 payload)
- InitCryptoSnd: maxPayload bytes (includes padding)
- InitCryptoRcv: 63+ bytes (41 header + 6 SN + 16 MAC + ≥8 payload)
- Data: 39+ bytes (9 header + 6 SN + 16 MAC + ≥8 payload)
Transport Layer Overhead (variable):
- No ACK, 24-bit offset: 8 bytes
- No ACK, 48-bit offset: 11 bytes
- With ACK, 24-bit offset: 18 bytes
- With ACK, 48-bit offset: 24 bytes
- pktMtuUpdate adds 2 bytes (included in init and first data packet)
Total Minimum Overhead (Data message with payload):
- Best case: 39 bytes (9 + 6 + 16 + 8 transport header)
- Typical: 39-47 bytes for data packets
- 1452-byte packet: ~2.7-3.2% overhead
LinkedMap: O(1) insertion, deletion, lookup, and Next/Prev traversal. Used for:
- Connection map (connId → conn)
- Stream map per connection (streamID → Stream)
- In-flight packets (packetKey → sendPacket)
- Receive segments (offset → data)
All buffer operations protected by mutexes:
SendBuffer.mu: Protects send buffer operationsReceiveBuffer.mu: Protects receive buffer operationsconn.mu: Protects connection stateListener.mu: Protects listener stateStream.mu: Protects stream read/write
Crypto Errors:
- Authentication failures logged and dropped silently
- Malformed packets logged and dropped
- Epoch mismatches handled with ±1 epoch tolerance
- Key rotation: tries current, previous, and next secrets during transition
Buffer Full:
- Send:
Write()returns partial bytes written - Receive: Packet dropped with
RcvInsertBufferFull
Connection Errors:
- RTO exhausted (5 attempts): Connection closed with error
- 30-second inactivity: Connection closed
- Sequence number exhaustion (2^95): Connection closed with error
// Server
listener, _ := qotp.Listen(qotp.WithListenAddr("127.0.0.1:8888"))
defer listener.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
listener.Loop(ctx, func(ctx context.Context, stream *qotp.Stream) error {
if stream == nil {
return nil // No data yet, continue
}
data, err := stream.Read()
if err == io.EOF {
return nil // Stream closed
}
if err != nil {
return err // Exit loop on error
}
if len(data) > 0 {
stream.Write([]byte("response"))
stream.Close()
}
return nil
})
// Client (in-band key exchange, 1-RTT)
listener, _ := qotp.Listen()
conn, _ := listener.DialString("127.0.0.1:8888")
stream := conn.Stream(0)
stream.Write([]byte("hello"))
// Client (out-of-band keys, 0-RTT)
pubKeyHex := "0x1234..." // Receiver's public key
conn, _ := listener.DialStringWithCryptoString("127.0.0.1:8888", pubKeyHex)
stream := conn.Stream(0)
stream.Write([]byte("hello"))// Read returns available in-order data.
// Returns io.EOF after FIN received and all data delivered.
// Returns nil data (not error) if no data available yet.
func (s *Stream) Read() ([]byte, error)
// Write queues data for transmission.
// Returns io.EOF if stream is closing/closed.
// May return partial write if buffer full.
func (s *Stream) Write(userData []byte) (int, error)
// Close initiates graceful close of send direction.
// Receive direction remains open until peer's FIN.
func (s *Stream) Close()
// IsClosed returns true when both directions fully closed.
func (s *Stream) IsClosed() bool
// IsCloseRequested returns true if Close() has been called.
func (s *Stream) IsCloseRequested() bool
// Ping queues a ping packet for RTT measurement.
func (s *Stream) Ping()// Address to listen on
qotp.WithListenAddr("127.0.0.1:8888")
// Custom max payload (default: interfaceMTU - 48, typically 1452)
qotp.WithMaxPayload(1200)
// Pre-configured identity key
qotp.WithPrvKeyId(privateKey)
// Derive key from seed
qotp.WithSeed([32]byte{...})
qotp.WithSeedHex("0x1234...")
qotp.WithSeedString("my-secret-seed")
// Custom network connection (for testing)
qotp.WithNetworkConn(conn)
// Key logging for Wireshark
qotp.WithKeyLogWriter(file)Protocol is experimental. Contributions welcome but expect breaking changes.