Encrypted, and still readable
Run tcpdump -i eth0 udp port 51820 next to a real VPN. Every packet
shows a source address, a destination address, two ports, and a length. The payload
is noise. You cannot read a single inner byte, but you can read the whole
conversation's shape: who, to whom, how often, how big.
The previous lesson left you with a tunnel that ships inner packets inside UDP datagrams. Everything in that datagram is in the clear. The inner IP header exposes your private overlay addresses. The inner payload exposes the request. Encryption fixes that. It cannot fix the outer header, and understanding why tells you what encryption can and cannot promise.
Routers must read the outer header
Every hop between the two machines does one thing: read the destination address, look it up in a routing table, forward. If the destination were encrypted, no router could forward the packet. So the outer IP and UDP headers travel in the clear by necessity. Only the UDP payload is yours to protect.
| visible to everyone on the path | hidden inside the ciphertext |
|---|---|
| outer src and dst IP | inner src and dst IP (100.64.x.x) |
| outer UDP ports | inner protocol and ports |
| packet size | payload content |
| timing and packet count | which inner connection a packet belongs to |
| that it is a VPN (the packet type byte) | — |
The frame, before and after
Step through the same frame you built in lesson 01, now carrying a tunneled web request. First in the clear, then encrypted. Watch which cells change and which cannot.
Authenticated encryption: ciphertext plus a tag
Scrambling the bytes is half the job. If an attacker can reach your UDP port, they
can send you any datagram, and the previous lesson's daemon would write
it straight into the kernel. Encryption alone does not stop that. Random garbage
decrypts to different random garbage, and the kernel would try to parse it as an
IP packet.
The fix is authenticated encryption with associated data, AEAD. WireGuard uses ChaCha20-Poly1305. ChaCha20 turns the plaintext into ciphertext of the same length. Poly1305 computes a 16-byte tag over the ciphertext. The receiver recomputes the tag first. If a single bit anywhere in the ciphertext changed, the tags differ, and the packet is dropped before any decryption happens. Forged packets never reach the TUN device. The 56-byte inner packet from the visualization becomes 56 bytes of ciphertext plus 16 bytes of tag.
Both operations need a nonce: a number used once. Encrypt two packets with the same key and the same nonce and the cipher stream repeats, and anyone who XORs the two ciphertexts gets the XOR of the two plaintexts. WireGuard makes the nonce a 64-bit counter, increments it per packet, and sends it in the clear. The receiver also uses it to reject replays: a counter it has already seen is dropped.
Keys are identity
There is no login on a mesh VPN. Each machine generates a key pair once. Its public key is its name. When machine A wants to reach machine B, it asks the coordination server (lesson 07's HTTP server, plus JSON) for B's public key and current endpoint, the public address B's NAT exposed in lesson 11. A and B then run a handshake that uses both key pairs to agree on a fresh session key. Every data packet after that is encrypted with the session key, and the receiver knows it came from B because only B's private key could have completed that handshake.
In the transport packet this shows up as a 4-byte receiver index. It is not a user id. It is the index of a session, and the session was established with a specific public key. Look up the index, find the key, find the peer.
The shape of a transport packet
Here is the WireGuard transport packet as a C struct. It is the UDP payload from the TUN lesson with 32 bytes of overhead: a 16-byte header before the ciphertext and a 16-byte tag after it. The protocol specifies the index and counter as little-endian, the opposite of IP's network byte order. Nothing forces a protocol to use big-endian; the specification decides, and you follow it.
Receiving one is five checks in a fixed order. Every failure is a silent drop. No error reply, because a reply tells an attacker that something is listening and which check they failed.
memcmp.
memcmp returns at the first differing byte, so a wrong tag that shares
its first 3 bytes with the real one takes measurably longer to reject than one that
shares none. An attacker who can time your replies learns the tag one byte at a time.
Use a constant-time compare that always touches all 16 bytes
(crypto_verify_16 in libsodium, CRYPTO_memcmp in OpenSSL),
or let the AEAD library's open function do the check, as above.
crypto_aead_chacha20poly1305_ietf_* functions
are the right call.
TLS is the same idea over TCP
When your browser talks to the coordination server over HTTPS, the layering is identical. TCP's header stays in the clear so the stream can be delivered and acknowledged. Inside the stream, TLS frames carry AEAD-encrypted records, each with its tag, each under a session key agreed in a handshake that used the server's public key. Different packet format, different handshake, same three parts: an outer header that must be readable, a nonce that must not repeat, and a tag that must verify before anything is trusted.
You have every piece
Look back at the track. A packet is bytes with headers prepended, and you can
parse one from a buffer. A socket is an fd with kernel buffers behind it. UDP
preserves message boundaries and asks nothing of the network, which is why it
carries the tunnel. TCP and HTTP give you a coordination server that hands out
public keys and endpoints. poll lets one thread watch a TUN fd and a
UDP socket. Routing sends overlay addresses into the TUN. NAT explains why your
peer has no reachable address, and hole punching gets around it. A TUN device
hands you the kernel's packets and takes yours back. AEAD makes the UDP payload
private and unforgeable.
That list is a mesh VPN. Not a sketch of one: the actual components of WireGuard and Tailscale, each of which you have now seen as bytes in memory and a handful of system calls. The next step on this site is a project course, Build a mesh VPN, where you assemble them into one program, in C, and run it between two machines that have never had a public address.
Encryption turns the UDP payload into ciphertext plus a tag that must verify before you trust a byte; the outer header stays readable because routers need it, so what you hide is content, never the conversation.