Build a mesh VPN · lesson 04

Keys are identity

Lesson 03 built a peer table keyed by a 32-byte array called pubkey, and every lookup compared it with memcmp. Nothing has yet said what those bytes are, where they come from, or why a table of IP addresses would not do. This lesson generates them.

in progress
15 min interactive

Who is 100.64.0.2?

The tunnel from lesson 02 trusted its peer because of where packets came from. A datagram from 203.0.113.9:51820 was written into tun0. Anyone who can put that source address on a UDP packet, which is anyone with a raw socket, becomes your peer. The cryptokey-routing table in lesson 03 moved the trust to a pubkey field, but it filled that field from a command-line argument you typed. Where does the value come from, and what makes it worth more than an address?

The answer is a pair of 32-byte numbers. One you keep. One you give to everyone. The two are related by a function that runs easily in one direction and, as far as anyone knows, cannot be run in the other. Everything the next three lessons build rests on that single asymmetry.

A keypair in two libsodium calls

src/crypto.c is the shortest file in the repo. Its job is to create, save, load and print static X25519 keys. "Static" means the key lives as long as the node does: it is the node, in the same way a passport number is a person to a border guard. Session keys that change every two minutes are lesson 06's business.

c
// src/crypto.c void crypto_keypair_generate(uint8_t pk[KEY_LEN], uint8_t sk[KEY_LEN]) { crypto_box_keypair(pk, sk); // sk = 32 random bytes, pk = sk · G } void crypto_pubkey_from_private(const uint8_t sk[KEY_LEN], uint8_t pk[KEY_LEN]) { /* X25519: the public key is the base point multiplied by the scalar. */ crypto_scalarmult_base(pk, sk); }

KEY_LEN is 32, defined in wire.h, and it is the same 32 for both halves. The private key sk is nothing more than 32 bytes from the kernel's random number generator. The public key pk is a point on an elliptic curve, Curve25519, obtained by multiplying a fixed base point G by the private scalar. crypto_box_keypair does both steps; crypto_scalarmult_base redoes only the second, which is what meshd pubkey needs when it is handed an existing private key.

The multiplication is the one-way door. Given sk, computing pk takes a few microseconds. Given pk, computing sk is the discrete logarithm problem on this curve, and the best known attack needs about 2126 operations. That is why the public half can be printed on a sticker.

💡
A keypair is a lock and a name at the same time. The public key is the name other nodes use for you. The private key is the only thing that can act under that name. An IP address is a name without a lock: anyone can write it into a packet header. That difference is the whole reason this lesson exists.

44 characters of base64

Thirty-two raw bytes do not paste well into a terminal. Every place the system shows a key to a human, and every place a key crosses the text-based control protocol in lesson 07, it is base64 with libsodium's sodium_base64_VARIANT_ORIGINAL: 32 bytes become 43 characters plus one = of padding, 44 in all. KEY_B64_LEN is 45 to hold the terminating NUL. The helpers in util.c are one call each.

c
// src/util.c void b64_encode(const uint8_t *bin, size_t len, char *out, size_t out_len) { sodium_bin2base64(out, out_len, bin, len, sodium_base64_VARIANT_ORIGINAL); } bool b64_decode(const char *text, uint8_t *out, size_t out_len) { size_t got = 0; if (sodium_base642bin(out, out_len, text, strlen(text), " \n", &got, NULL, sodium_base64_VARIANT_ORIGINAL) != 0) return false; return got == out_len; // exactly 32 bytes, or it is not a key }

The " \n" argument tells the decoder which characters to skip, so the trailing newline from fgets does not break parsing. The last line matters: a 31-byte or 33-byte decode is rejected. A key is exactly 32 bytes or it is garbage.

The file that must not leak

meshd genkey ~/.mesh/private.key writes one base64 line. The file is created with O_EXCL and mode 0600. O_EXCL means the call fails if the file already exists: an identity that silently gets overwritten is an identity that silently changes, and every peer table in the mesh would still hold the old key. Mode 0600 is read and write for the owner only, which you saw in the file-descriptors lesson as the third argument to open.

c
// src/crypto.c bool crypto_save_private(const char *path, const uint8_t sk[KEY_LEN]) { /* O_EXCL: never silently overwrite an identity. */ int fd = open(path, O_WRONLY | O_CREAT | O_EXCL, 0600); if (fd < 0) { log_err("create %s: %m", path); return false; } char b64[KEY_B64_LEN]; crypto_key_to_b64(sk, b64); bool ok = write(fd, b64, 44) == 44 && write(fd, "\n", 1) == 1; close(fd); return ok; } bool crypto_load_private(const char *path, uint8_t sk[KEY_LEN], uint8_t pk[KEY_LEN]) { FILE *f = fopen(path, "r"); // error handling elided — see the repo struct stat st; if (fstat(fileno(f), &st) == 0 && (st.st_mode & 077)) log_warn("%s is readable by others; chmod 600 it", path); char line[128] = { 0 }; bool ok = fgets(line, sizeof line, f) != NULL; fclose(f); if (!ok || !crypto_key_from_b64(line, sk)) return false; crypto_pubkey_from_private(sk, pk); // the file holds only sk; pk is recomputed return true; }

Notice what the file does not contain: the public key. There is no need. The loader recomputes it with crypto_scalarmult_base every time, so the two halves can never disagree. The st.st_mode & 077 test checks the group and other permission bits; if any is set, the daemon warns and continues. A product would refuse. Here you want to see the warning once and understand it.

🚫
Copy private.key to a second machine and you have one node in two places. Both will claim the same overlay address, both will open the other's handshakes, and the coordination server will see one identity flapping between two endpoints. A stolen private key is the same situation with a hostile second machine, and nothing in the protocol can tell the difference. The threat-model row from lesson 01: someone who steals a private key can impersonate that node until it is removed from coordd.

Why two public keys make one secret

Here is the entire mathematical content of the course, in one paragraph. Alice has aSk and publishes aPk = aSk · G. Bob has bSk and publishes bPk = bSk · G. Alice computes crypto_scalarmult(shared, aSk, bPk), which is aSk · bSk · G. Bob computes crypto_scalarmult(shared, bSk, aPk), which is bSk · aSk · G. Scalar multiplication commutes, so the two 32-byte results are identical. Neither value crossed the wire. Eve, holding aPk and bPk, would have to compute aSk · bSk · G from two points without either scalar, and that is the computational Diffie-Hellman problem. This repo never calls crypto_scalarmult directly; it calls crypto_box_easy in lesson 06 and crypto_kx_*_session_keys right after, and both do exactly this multiplication inside, then hash the result into a symmetric key.

Step through the visualization. Watch which values stay inside the dashed boxes, which ones cross the wire, and what Eve is left holding at the end.

key exchange — two private keys, one shared secret
Click next to start the walkthrough.

A public key is a better name than an address

Consider the three ways you could name a node in the peer table.

By IP address. The address changes when the laptop moves from the office to a phone hotspot. Behind NAT it is not even unique: two nodes on two home networks can both be 192.168.1.7. And anyone can forge it in a header. The NAT lesson showed you that the address a server sees is not even the one the node has.

By hostname. A hostname is whatever DNS says it is. The resolver you trust is the one your coffee shop handed out over DHCP. A name that a third party can redefine is not an identity; it is a pointer.

By public key. It does not change when the node moves. It is unique by construction: 2252 possible values make a collision less likely than a hardware fault. And it comes with a proof: only the holder of the private key can open a box sealed to it, so a node can demonstrate that it is the owner of the name every time it speaks. Lesson 06 turns that proof into a handshake. Lesson 09 uses it to let a peer roam to a new address safely: when an authenticated packet arrives from an unexpected endpoint, the key tells you it is still the same node.

This is why coordd's node table, relayd's registration table, and struct peer are all keyed by the 32 bytes and not by anything else. WireGuard calls the design "cryptokey routing" and Tailscale builds its entire control plane on top of it: a machine is its key, and the server's job is to map keys to the addresses where they can currently be found.

✅
Private key: one file, one machine, mode 0600, never copied. Public key: anywhere. Paste it in a chat, commit it to a config file, let the coordination server hand it out. The 32 bytes at offset 1 of every handshake packet in lesson 06 are this key, in the clear, on purpose.

Run it

Build the project, generate an identity, and derive its public half. The two subcommands are cmd_genkey and cmd_pubkey in src/meshd.c; genkey prints the key to stdout when given no path.

sh
make mkdir -p ~/.mesh && ./meshd genkey ~/.mesh/private.key ls -l ~/.mesh/private.key ./meshd pubkey < ~/.mesh/private.key ./meshd pubkey < ~/.mesh/private.key # same input, same output: pk is a function of sk ./meshd genkey ~/.mesh/private.key # a second time ./meshd genkey # no path: print to stdout, do not save
sh
-rw------- 1 you you 45 Oct 19 10:02 /home/you/.mesh/private.key kQ3vZl1yH8bN2cRfW7tUeJmPd0aXsGhLo6iV9wBqCzE= kQ3vZl1yH8bN2cRfW7tUeJmPd0aXsGhLo6iV9wBqCzE= 10:02:41.318 err create /home/you/.mesh/private.key: File exists 7pL2mXaQ9sD4fGhJkZ1nB8vC3wE6rTyU0iOoPaSdFgI=

Your key text will differ. Check the shape: 44 characters, the last one =, and pubkey gives the same answer every time. The File exists line is O_EXCL doing its job. The file is 45 bytes: 44 of base64 plus the newline. Run chmod 644 on it and start meshd up later in the course to see the permissions warning.

Nothing on the network has changed yet. The tunnel from lesson 03 still sends every packet in the clear. But you now hold the two numbers that every later lesson is built from, and you know which one stays home.

one-line takeaway

A node's name is its 32-byte public key: unlike an address or a hostname, it cannot be forged or reassigned, and the matching private key is the one thing that can act under it.