| Internet-Draft | UDPN | May 2026 |
| Samsonov | Expires 3 November 2026 | [Page] |
This document specifies the UDP Datagram Privacy Network (UDPN) protocol, version 1.0. UDPN provides an authenticated, encrypted Layer 3 tunnel over UDP with traffic obfuscation designed to resist deep packet inspection (DPI) and active probing. All packets are wrapped in DTLS 1.2 ApplicationData records. The protocol uses the Noise_NK handshake pattern with X25519 Diffie-Hellman and ChaCha20-Poly1305 AEAD encryption.¶
This Internet-Draft is submitted in full conformance with the provisions of BCP 78 and BCP 79.¶
Internet-Drafts are working documents of the Internet Engineering Task Force (IETF). Note that other groups may also distribute working documents as Internet-Drafts. The list of current Internet-Drafts is at https://datatracker.ietf.org/drafts/current/.¶
Internet-Drafts are draft documents valid for a maximum of six months and may be updated, replaced, or obsoleted by other documents at any time. It is inappropriate to use Internet-Drafts as reference material or to cite them other than as "work in progress."¶
This Internet-Draft will expire on 2 November 2026.¶
Copyright (c) 2026 IETF Trust and the persons identified as the document authors. All rights reserved.¶
This document is subject to BCP 78 and the IETF Trust's Legal Provisions Relating to IETF Documents (https://trustee.ietf.org/license-info) in effect on the date of publication of this document. Please review these documents carefully, as they describe your rights and restrictions with respect to this document.¶
UDPN creates a Layer 3 IP tunnel over UDP suitable for networks where deep packet inspection, port blocking, or active probing is used against tunneling traffic.¶
Three threat models are addressed:¶
Passive observation: all payload bytes are encrypted and computationally indistinguishable from random data.¶
Active probing: the server silently discards every packet it cannot cryptographically verify. An adversary receives no response whatsoever, making the endpoint indistinguishable from a closed UDP port.¶
Traffic correlation: periodic port hopping changes the UDP 5-tuple; random per-packet padding obscures payload lengths; jittered keepalive intervals resist timing fingerprinting.¶
All packets are formatted as DTLS 1.2 ApplicationData records to blend with legitimate DTLS/CoAP traffic on the wire.¶
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in BCP 14 [RFC2119] [RFC8174] when, and only when, they appear in all capitals.¶
A UDPN session proceeds as follows:¶
Initiator Responder
| |
| DTLS[epoch=0, seq=0] |
| routing_tag(4) + Noise msg1 ──────>|
| | verify routing_tag
| | Noise ReadMessage1
| | assign session_epoch
| DTLS[epoch=0, seq=random] |
| Noise msg2 <───────────────────────|
| |
| derive transport keys | derive transport keys
| set DTLS epoch = session_epoch |
| TUN interface UP | TUN interface UP
| |
| DTLS[epoch=session_epoch, seq=N] |
| DATA/KEEPALIVE/... <──────────────>|
¶
Every UDPN packet starts with a 13-byte DTLS 1.2 record header:¶
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Type = 0x17 | Ver = 0xFE | Ver = 0xFD |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| DTLS Epoch (16) | Sequence bits 47..32 (16) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Sequence bits 31..0 (32) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Payload Length (16) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
¶
Handshake packets use DTLS Epoch = 0. A packet with DTLS Epoch = 0 and DTLS payload length less than 4 bytes MUST be silently discarded. Such a packet cannot contain a valid routing_tag (msg1 minimum: 36+ bytes) nor a complete Noise msg2 (minimum 32 bytes for e_pub alone).¶
The msg1 payload layout is:¶
Offset Size Field
────── ──── ─────────────────────────────────────────────
0 4 routing_tag BLAKE2s(e_pub||s_pub)[0:4]
4 32 e_pub Initiator ephemeral public key
36 * ciphertext ChaCha20-Poly1305(inner_payload)
36+* 16 AEAD_tag Poly1305 authentication tag
¶
The msg2 payload layout (no routing_tag) is:¶
Offset Size Field
────── ──── ─────────────────────────────────────────────
0 32 e_pub Responder ephemeral public key
32 * ciphertext ChaCha20-Poly1305(inner_payload)
32+* 16 AEAD_tag Poly1305 authentication tag
¶
In both messages the AEAD tag immediately follows the ciphertext, matching standard AEAD.Seal() output per [RFC8439].¶
Transport packets use DTLS Epoch = session_epoch. Their payload is the output of a single AEAD.Seal() call: ciphertext followed immediately by the 16-byte Poly1305 authentication tag.¶
The first 8 bytes of every transport plaintext are the inner header:¶
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Type (8) | Flags (8) | Hop Epoch (16) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Inner Sequence (32) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
¶
Total per-packet overhead: 13 (DTLS) + 8 (inner header) + 16 (AEAD tag) = 37 bytes, plus padding.¶
Each connection uses a unique X25519 keypair generated by the Responder. The private key is kept exclusively by the Responder. The public key is distributed to the Initiator out-of-band (e.g., a configuration file). Key clamping follows [RFC7748] Section 5.¶
UDPN uses the Noise Protocol Framework [NOISE] with:¶
Pattern: NK
DH: 25519 (X25519, RFC 7748)
Cipher: ChaChaPoly (ChaCha20-Poly1305, RFC 8439)
Hash: SHA256
Full protocol name: Noise_NK_25519_ChaChaPoly_SHA256
Pattern NK:
<- s (premessage: Responder static public key)
...
-> e, es (msg1: Initiator ephemeral + DH)
<- e, ee (msg2: Responder ephemeral + DH)
¶
The NK pattern provides: Responder authentication (Initiator knows its public key); Initiator anonymity (Responder learns nothing about Initiator identity); and forward secrecy (both ephemeral keys are discarded after the handshake).¶
Prologue: empty byte sequence (zero bytes).¶
Upon completing the handshake, both parties call split() to derive two ChaCha20-Poly1305 keys (c1, c2). The Initiator sends with c1 and receives with c2. The Responder sends with c2 and receives with c1. The transport key is NOT rotated within a session; each new session derives fresh independent keys through new ephemeral DH.¶
ChaCha20-Poly1305 requires a 96-bit (12-byte) nonce, constructed as follows:¶
nonce[0..3] = 0x00 0x00 0x00 0x00 (4 zero bytes)
nonce[4..11] = DTLS Sequence (64-bit, little-endian)
Step-by-step:
(1) Read DTLS Sequence from wire: 6 bytes big-endian -> uint64
(upper 16 bits = 0).
(2) Encode uint64 as little-endian into nonce[4..11].
Example: DTLS Sequence = 1 (0x000000000001 on wire)
Decoded uint64: 0x0000000000000001
nonce[4..11]: 01 00 00 00 00 00 00 00 (little-endian)
¶
This follows [RFC8439] Section 2.4. Additional Data (AD) passed to AEAD is always empty.¶
The Initiator prepends a 4-byte routing tag to msg1 to allow the Responder to identify the target connection without attempting O(N) Noise decryptions. The tag uses BLAKE2s-256 [BLAKE2]:¶
routing_tag = BLAKE2s-256(e_pub || s_pub)[0:4]¶
where e_pub is the Initiator's ephemeral public key and s_pub is the Responder's static public key for the target connection. The Responder scans connections computing BLAKE2s-256(e_pub || conn.s_pub)[0:4] until a match is found, then performs one full Noise decryption to authenticate.¶
Note: the routing tag does not eliminate the X25519 operation. It reduces Nx(X25519+AEAD) to NxBLAKE2s + 1x(X25519+AEAD). At N=1000 this is approximately 500ms reduced to 1ms for the lookup phase.¶
Collision probability is approximately N/2^32 per handshake attempt. On collision, both matching connections are tried; the AEAD step resolves the ambiguity.¶
The Initiator sends a UDP packet with DTLS Epoch = 0 and Sequence = 0. The DTLS payload contains: routing_tag (4 bytes), followed by the Noise msg1 bytes (e_pub + ciphertext + tag).¶
The encrypted inner_payload contains:¶
Offset Size Field
────── ──── ─────────────────────────────────────────────
0 8 conn_id_hint BLAKE2s derivative of conn id
8 2 hop_interval Hop interval in minutes (uint16 BE)
10 4 pool_hash HMAC-SHA256 of port pool (uint32 BE,
first 4 bytes)
14 P padding P random bytes (P >= padding.min)
¶
The conn_id_hint, hop_interval, and pool_hash fields are informational. The Responder validates minimum payload size (14 bytes) but does not use these values for session routing or authentication.¶
The Responder replies with DTLS Epoch = 0 and a random Sequence value. The DTLS payload is the Noise msg2 bytes (e_pub + ciphertext + tag).¶
The encrypted inner_payload contains:¶
Offset Size Field
────── ──── ─────────────────────────────────────────────
0 2 session_epoch DTLS epoch for this session (uint16 BE)
2 P padding P random bytes
¶
session_epoch is a randomly chosen value in [0x0001..0xFFFE]. The Responder MUST NOT assign session_epoch = 0x0000, as zero is reserved to identify handshake packets; transport packets with epoch 0 would be indistinguishable from handshake packets. The Responder MUST also verify the epoch is not in use by another active session; if a collision is found, a new value MUST be drawn.¶
After a successful exchange, both parties:¶
Derive transport keys via split().¶
Reset the DTLS Sequence (Noise nonce) counter to 0.¶
Reset the Inner Sequence counter to 0.¶
Reset the inner sliding-window replay state.¶
The Initiator additionally sets its DTLS Epoch to session_epoch and brings the TUN interface UP. The Responder records the session DTLS Epoch, Initiator src IP:port, and local UDP port, and brings its TUN interface UP. If a session already existed for this connection, it is evicted first.¶
Claim nonce N = dtlsSeqTx.fetch_add(1) atomically.¶
Claim Inner Sequence S = innerTxSeq.fetch_add(1) atomically.¶
Choose padding P = padding.min + PRNG(padding.max + 1).¶
Construct plaintext: inner_header (8 bytes) || L3 packet || P zero bytes.¶
Encrypt: ciphertext = AEAD_Encrypt(send_key, nonce=N, AD="", plaintext).¶
Construct DTLS record: Type=0x17, Ver=0xFEFD, Epoch=session_epoch, Seq=N, Length=len(ciphertext).¶
Enqueue for transmission.¶
Check DTLS Type=0x17, Version=0xFEFD. On mismatch: DISCARD silently.¶
If DTLS Epoch = 0: route to handshake processing.¶
Look up active session by DTLS Epoch. If none: DISCARD silently.¶
ACL check (server only). On failure: DISCARD, increment acl_drop.¶
Decrypt: plaintext = AEAD_Decrypt(recv_key, nonce=DTLS_Seq, AD="", ciphertext). On failure: DISCARD, increment replay_drop.¶
Parse inner header from plaintext[0..7].¶
Sliding-window check on Inner Sequence. On failure: DISCARD, increment replay_drop.¶
Update keepalive watchdog (touch last-seen timestamp).¶
Server only: update routing state (see Section 9.3).¶
Dispatch on inner Type (see Section 7.3).¶
Both endpoints send KEEPALIVE packets at random intervals drawn from [T*0.8, T*1.2] where T is the configured keepalive interval in seconds. Jitter prevents interval-based fingerprinting.¶
The watchdog fires after T * timeout_factor seconds of inactivity (default timeout_factor = 3, minimum 1). On timeout, the Initiator tears down the session and schedules reconnection; the Responder tears down and waits for a new handshake.¶
Given a sorted pool P of N port numbers:¶
SelectPort(key, session_id, epoch, direction, P):
mac = HMAC-SHA256(key=key,
data=uint64_BE(session_id) || uint16_BE(epoch)
|| uint8(direction))
index = uint16_BE(mac[0:2]) mod N
return P[index]
direction = 0 for destination port
direction = 1 for source port
¶
The direction byte ensures dst_port and src_port are derived independently, eliminating structural collisions for all pool sizes. If SelectPort(key, sid, epoch, 1, P) == SelectPort(key, sid, epoch, 0, P), the source port MUST be advanced to the next entry in the pool.¶
In UDPN v1.0, key = Responder static public key, session_id = 0.¶
At each hop_interval (minutes):¶
Increment hop_epoch by 1 (mod 65536).¶
Compute dst_port = SelectPort(s_pub, 0, hop_epoch, 0, pool).¶
Compute src_port = SelectPort(s_pub, 0, hop_epoch, 1, pool).¶
Update internal dstAddr to (server_ip, dst_port).¶
Rebind local UDP socket to src_port. On bind failure, try other pool ports (excluding dst_port). If all fail, skip this hop.¶
Begin sending with Hop Epoch = hop_epoch in the inner header.¶
The DTLS Sequence (Noise nonce) MUST NOT be reset on a hop event. Resetting would cause replay detection failures on the Responder.¶
In step 9 of Section 7.2, the Responder updates routing state as follows:¶
If inner.HopEpoch == current hop_epoch:¶
If inner.HopEpoch != current hop_epoch, compute delta = (incoming - current) mod 65536:¶
After a hop, packets arriving with the old hop_epoch are still accepted as long as they pass AEAD verification (same session key across hops) and the inner sequence window check. No explicit grace timer is required.¶
The DTLS Sequence is a monotonically increasing uint64 counter per session, never reset within a session. Replay of any packet with a previously used DTLS Sequence fails AEAD verification. This is the primary replay barrier.¶
A 1024-packet sliding window operates on the 32-bit Inner Sequence field, providing secondary duplicate detection for reordered packets (e.g., ECMP path changes or Wi-Fi retransmissions).¶
Let WINDOW_SIZE = 1024. Processing Inner Sequence S (uint32):¶
diff = (S - maxSeen) mod 2^32
if diff < 2^31: # S is newer than maxSeen
if diff >= WINDOW_SIZE:
bits = 0 # far ahead -- reset bitmap
elif diff > 0:
bits <<= diff # slide window forward
maxSeen = S
bits[0] |= 1 # mark S as received
return ACCEPT
else: # S is older than maxSeen
offset = (maxSeen - S) mod 2^32
if offset >= WINDOW_SIZE: return DISCARD # too old
if bit[offset] is set: return DISCARD # duplicate
set bit[offset]
return ACCEPT
¶
The window is reset at each new session. Window size rationale: 1024 absorbs reordering from ECMP routing and Wi-Fi retransmission buffers without false positives. WireGuard uses 2048; OpenVPN uses 128. Implementation: the 1024-bit bitmap is stored as [16]uint64.¶
The Responder maintains a TTL cache of Initiator ephemeral public keys (e_pub) observed in msg1, with a TTL of 30 minutes. If an e_pub is seen a second time, the packet is silently discarded. Memory: approximately 32 bytes per entry; at 1 handshake/second over 30 minutes ≈ 57 KB.¶
Each outgoing packet includes random padding: pad_length = padding.min + PRNG(padding.max + 1), where PRNG is a non-cryptographic uniform PRNG (PCG algorithm). Padding length is not security-sensitive; only unpredictability of sizes is required. Default values: padding.min = 16, padding.max = 128. Padding bytes on the wire are zero; receivers ignore them.¶
IDLE ──────────────────────────────────────> CONNECTING CONNECTING ─(msg2 received)────────────────> ESTABLISHED CONNECTING ─(timeout * 3 attempts)─────────> IDLE (reconnect delay) ESTABLISHED ─(keepalive timeout)────────────> IDLE (reconnect delay) ESTABLISHED ─(DISCONNECT received)──────────> IDLE (reconnect delay) ESTABLISHED ─(hop timer fires)──────────────> HOPPING -> ESTABLISHED¶
Initiator reconnect delay: configurable (default 5 seconds). On graceful shutdown (SIGTERM/SIGINT), three DISCONNECT packets are sent in quick succession, followed by a 300 ms drain delay before exit.¶
The Responder MUST silently discard: packets with DTLS type other than 0x17; packets with DTLS version other than 0xFEFD; epoch-0 packets failing Noise msg1 decryption; transport packets failing AEAD verification; packets with epoch not matching any active session. Under no circumstances MUST the Responder send any response to an unauthenticated packet, including ICMP Port Unreachable.¶
The Noise_NK handshake derives session keys from a combination of the Initiator's fresh ephemeral key pair and the Responder's fresh ephemeral key pair. Compromise of the Responder's static private key after a session concludes does NOT reveal session keys.¶
Three independent mechanisms prevent replay: (a) handshake ephemeral key cache; (b) AEAD nonce monotonicity; (c) inner sequence sliding window.¶
Mitigations: random per-packet padding obscures sizes; keepalive jitter (+/-20%) obscures timing; port hopping changes the UDP 5-tuple. Limitations: total traffic volume and packet rate are not obscured; an adversary observing traffic before and after a hop may correlate flows by timing proximity.¶
ChaCha20-Poly1305 is preferred over AES-256-GCM because its software implementation is constant-time regardless of hardware AES support. AES-GCM without AES-NI instructions is vulnerable to cache-timing attacks [BERNSTEIN]. On virtualised platforms (KVM/QEMU), AES-NI passthrough cannot be universally guaranteed.¶
UDPN does not implement PMTUD for the outer UDP encapsulation; outer packets are sent without the DF bit. Inner oversized L3 packets are dropped and replaced with ICMP Fragmentation Needed (IPv4, [RFC1191]) or ICMPv6 Packet Too Big (IPv6, [RFC1981]) messages. Operators SHOULD configure tun_mtu = min(path_mtu, 1500) - 37.¶
The TUN file descriptor MUST be opened without O_NONBLOCK. Reads are performed via blocking syscall with the goroutine locked to its OS thread (runtime.LockOSThread). TUN MTU = configured_mtu - 37.¶
With N listening sockets (typically 257), a naive one-goroutine-per-socket model creates N OS threads blocked in recvfrom(2). The RECOMMENDED approach is edge-triggered epoll(7) with 2 worker goroutines, each draining ready file descriptors until EAGAIN.¶
Implementations SHOULD use sendmmsg(2) to send multiple outgoing packets per syscall. A batch size of 32 is RECOMMENDED. Fall back to individual sendmsg(2) if unavailable.¶
For maximum throughput, the encrypt hot path SHOULD be lock-free:¶
Transport state pointer: atomic.Pointer (nil = no session).¶
DTLS epoch and Hop epoch: packed into a single atomic uint32.¶
DTLS Sequence (Noise nonce): atomic uint64, incremented with Add.¶
Inner Sequence: atomic uint32, incremented with Add.¶
Multiple goroutines can encrypt concurrently without mutex contention, each atomically claiming a unique nonce. The AEAD object (cipher.AEAD) is created once at session start and reused; Seal/Open are goroutine-safe.¶
This document has no IANA actions. UDPN uses UDP ports chosen by the operator. The ciphertext carried in DTLS content type 0x17 is indistinguishable from random data and is not registered with IANA.¶