Networking · lesson 13

What encryption hides

You encrypted the tunnel. tcpdump still shows exactly which two machines are talking, how much, and when. That is not a bug. Routers need the outer header, so encryption can only ever hide what comes after it. This lesson is about where that line falls and what the bytes on each side look like.

in progress
12 min interactive

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) —
💡
Encryption hides content. It does not hide metadata. Sizes and timing leak enough to tell a video call from an SSH session. Hiding that needs padding, cover traffic, or routing through relays, which cost bandwidth and latency. A VPN does not try. Know what you are buying.

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.

encrypted packet — the same frame, before and after
Click next to start the walkthrough.

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.

🚫
Never reuse a nonce under the same key. With a stream cipher this is not a weakness, it is a total break: the attacker recovers plaintext without the key. A counter that resets when your process restarts, or two threads sharing one counter without a lock, both reuse nonces. WireGuard sidesteps the restart problem by negotiating a fresh key every two minutes, so counters start from zero under a key nobody has used.

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.

c
#include <stdint.h> // WireGuard transport data message. Offsets from the start of the UDP payload. // Fields are little-endian. Fixed-width types so sizeof() is 16 on every platform. struct wg_transport { uint8_t type; // off 0 : 4 = transport data uint8_t reserved[3]; // off 1 : must be zero uint32_t receiver; // off 4 : which session, hence which peer key uint64_t counter; // off 8 : nonce; also the replay check // off 16 : ciphertext, same length as the inner IP packet // off 16+n: 16-byte Poly1305 tag }; #define WG_HDR_LEN 16 #define WG_TAG_LEN 16 // read a little-endian u64 without caring what the host is static uint64_t rd_le64(const uint8_t *p) { uint64_t v = 0; for (int i = 7; i >= 0; i--) v = (v << 8) | p[i]; return v; }

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.

c
// sketch: datagram of n bytes arrived on udpfd. Returns inner packet length, or -1 to drop. int wg_receive(const uint8_t *pkt, size_t n, uint8_t *inner) { if (n < WG_HDR_LEN + WG_TAG_LEN) return -1; // 1. long enough to hold a header + tag if (pkt[0] != 4) return -1; // 2. transport data uint32_t idx; memcpy(&idx, pkt + 4, 4); // (little-endian host assumed here) struct session *s = session_lookup(idx); if (!s) return -1; // 3. known session → peer public key → session key uint64_t ctr = rd_le64(pkt + 8); if (replay_seen(s, ctr)) return -1; // 4. counter not already accepted const uint8_t *ct = pkt + WG_HDR_LEN; size_t len = n - WG_HDR_LEN - WG_TAG_LEN; const uint8_t *tag = ct + len; // 5. verify tag, then decrypt. One call does both; it fails if the tag is wrong. if (chacha20poly1305_open(inner, ct, len, tag, s->recv_key, ctr) != 0) return -1; replay_mark(s, ctr); return (int)len; // caller: write(tunfd, inner, len) }
⚠️
Never compare a tag with 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.
✅
Use a vetted AEAD implementation. Do not write the cipher. The struct, the parsing, the replay window, and the fd plumbing are yours to build and the point of this track. ChaCha20 and Poly1305 are two hundred lines where a single wrong carry or a timing-dependent branch turns the whole tunnel into plaintext. libsodium's 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.

one-line takeaway

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.