WireGuard: Cryptokey Routing and the Pre-Shared Key

Authors

K19G

grok-bot

Updated

October 31, 2026

WireGuard: Cryptokey Routing and the Pre-Shared Key

The site-tunnel chapter used WireGuard as one route-based VPN option among several. This chapter opens it up. WireGuard has two ideas you must understand before you run it in production. Cryptokey routing means that a peer’s AllowedIPs list is both its routing table and its source-address filter. The optional pre-shared key (PSK) is a 32-byte symmetric secret mixed into every handshake. In 2026 the PSK is getting attention as a cheap post-quantum hedge: VPN providers now generate it by default for router and native-client configs. Both ideas are easy to misconfigure, and both fail silently. This chapter makes them fail on purpose in a two-namespace lab and reads the evidence.

Learning goals

By the end of this chapter you can:

  • Build a WireGuard tunnel between two Linux network namespaces with wg and ip, without config files
  • Explain why AllowedIPs drops a correctly encrypted packet and where that drop is counted
  • Recognise WireGuard’s “no peer for this destination” behaviour
  • Add a PSK, explain exactly what it does and does not protect against, and rotate it live
  • Diagnose a PSK mismatch from a packet capture (both handshake messages are on the wire, yet the tunnel never comes up)

Lab platform: Debian 13, Linux 6.12, wireguard-tools v1.0.20210914 (the current Debian package), iproute2, tcpdump 4.99.5. All commands need root. Every output below is pasted from a run.

Concepts in one page

           siteA (198.51.100.1)                         siteB (198.51.100.2)
 ┌────────────────────────────────────┐ veth "internet" ┌────────────────────────────────────┐
 │ wg0 10.0.0.1/24                    │◄──── UDP ──────►│ wg0 10.0.0.2/24     lo 192.0.2.1/24│
 │ peer B: allowed 10.0.0.2/32,       │   51820         │ peer A: allowed 10.0.0.1/32        │
 │         192.0.2.0/24               │                 │                                    │
 └────────────────────────────────────┘                 └────────────────────────────────────┘
Direction What AllowedIPs does
Outbound (a packet leaves via wg0) Longest-prefix match of the destination against every peer’s AllowedIPs picks the peer, and so the key and endpoint. No match means the packet is not sent, and the sender gets an error.
Inbound (a packet arrives and decrypts) The decrypted inner source must fall inside the AllowedIPs of the peer whose key decrypted it. Otherwise it is dropped.

The kernel route only gets a packet to wg0. AllowedIPs decides which peer, if any.

The handshake is Noise IKpsk2 (Curve25519, ChaCha20-Poly1305, BLAKE2s). There are two messages. The initiation (type 1, 148 bytes of UDP payload) carries the initiator’s encrypted static key and a timestamp. The response (type 2, 92 bytes) carries the responder’s ephemeral key and an empty AEAD box. The PSK is hashed into the chaining key while the response is being built. Without a PSK the protocol uses 32 zero bytes in its place.

What the PSK buys: if Curve25519 is broken later (the “harvest now, decrypt later” scenario with a large quantum computer), traffic recorded today stays confidential, provided the PSK itself was never exposed. What it does not buy: it is not a post-quantum key exchange, it does not replace the public keys, and a PSK that was sent over a classical channel (for example a TLS session protected by ECDHE) is only as strong as that channel. Protocols such as Rosenpass run a post-quantum key exchange alongside WireGuard and feed the result into the PSK slot, rotating it. That is the direction to take when this matters.

Lab build

#!/usr/bin/env bash
# lab-up.sh - two namespaces, a veth "internet", and a WireGuard tunnel between them
set -euo pipefail
ip netns add siteA; ip netns add siteB
ip link add vA netns siteA type veth peer name vB netns siteB
ip -n siteA addr add 198.51.100.1/24 dev vA; ip -n siteA link set vA up; ip -n siteA link set lo up
ip -n siteB addr add 198.51.100.2/24 dev vB; ip -n siteB link set vB up; ip -n siteB link set lo up

umask 077
for s in A B; do wg genkey | tee $s.key | wg pubkey > $s.pub; done
wg genpsk > ab.psk

# create wg0 inside each namespace: a WireGuard interface sends its UDP from the namespace it was born in
ip -n siteA link add wg0 type wireguard
ip -n siteB link add wg0 type wireguard
ip netns exec siteA wg set wg0 private-key A.key listen-port 51820 \
   peer "$(cat B.pub)" preshared-key ab.psk endpoint 198.51.100.2:51820 allowed-ips 10.0.0.2/32,192.0.2.0/24
ip netns exec siteB wg set wg0 private-key B.key listen-port 51820 \
   peer "$(cat A.pub)" preshared-key ab.psk endpoint 198.51.100.1:51820 allowed-ips 10.0.0.1/32
ip -n siteA addr add 10.0.0.1/24 dev wg0; ip -n siteA link set wg0 up
ip -n siteB addr add 10.0.0.2/24 dev wg0; ip -n siteB link set wg0 up
# siteB also owns a LAN prefix that siteA routes into the tunnel
ip -n siteB addr add 192.0.2.1/24 dev lo
ip -n siteA route add 192.0.2.0/24 dev wg0
echo "lab up"
#!/usr/bin/env bash
# lab-down.sh - remove everything lab-up.sh created
ip netns del siteA 2>/dev/null; ip netns del siteB 2>/dev/null
rm -f A.key A.pub B.key B.pub ab.psk wrong.psk
echo "lab down"

wg genpsk produces 32 random bytes in base64, the same format as a private key. The PSK is configured per peer and must be identical on both ends. umask 077 keeps the key files private. In production these values live in /etc/wireguard/wg0.conf (mode 600) or in your configuration management tool’s secret store, never in a world-readable file.

$ sudo ./lab-up.sh
lab up
$ sudo ip netns exec siteA ping -c 2 10.0.0.2 | tail -2
2 packets transmitted, 2 received, 0% packet loss, time 1015ms
rtt min/avg/max/mdev = 0.547/0.590/0.633/0.043 ms
$ sudo ip netns exec siteA wg show
interface: wg0
  public key: SfKQSp7pB2JRf6CnHF048gHOIul0sUi0k9lCH/V9P2I=
  private key: (hidden)
  listening port: 51820

peer: SVXjcMNZVP6LGPEc+CYMNGGQ0/4KHY05bo34Oc5MuVg=
  preshared key: (hidden)
  endpoint: 198.51.100.2:51820
  allowed ips: 10.0.0.2/32, 192.0.2.0/24
  latest handshake: 1 second ago
  transfer: 348 B received, 404 B sent
$ sudo ip -n siteA link show wg0
3: wg0: <POINTOPOINT,NOARP,UP,LOWER_UP> mtu 1420 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000

The handshake completed on the first ping (latest handshake: 1 second ago), and wg show reports the PSK as (hidden). Its presence is visible and its value is not. The interface MTU is 1420 by default. That leaves room for the outer IPv6 header (40), UDP (8), and WireGuard’s 32 bytes of data-message overhead (a 16-byte header plus a 16-byte Poly1305 tag) inside a 1500-byte path. Over IPv4 there are 20 bytes to spare.

Drill 1: cryptokey routing drops a perfectly encrypted packet

#!/usr/bin/env bash
# drill-cryptokey.sh - AllowedIPs is a routing table outbound and a source filter inbound
set -euo pipefail
ip -n siteA addr add 203.0.113.5/32 dev lo           # an address siteB never allowed for this peer
echo "== inside the allowed source (10.0.0.1 -> 10.0.0.2)"
ip netns exec siteA ping -c 2 -W 1 10.0.0.2 | tail -2
echo "== outside it (203.0.113.5 -> 10.0.0.2)"
ip netns exec siteA ping -c 3 -W 1 -I 203.0.113.5 10.0.0.2 | tail -2 || true
echo "== siteB wg0 counters (decrypted fine, then dropped: src not in peer's allowed-ips)"
ip -n siteB -s link show wg0 | sed -n '3,4p'
echo "== a route into wg0 that no peer's allowed-ips covers"
ip -n siteA route add 198.18.0.0/15 dev wg0
ip netns exec siteA ping -c 1 -W 1 198.18.0.1 2>&1 | head -2 || true
$ sudo ./drill-cryptokey.sh
== inside the allowed source (10.0.0.1 -> 10.0.0.2)
2 packets transmitted, 2 received, 0% packet loss, time 1028ms
rtt min/avg/max/mdev = 0.138/0.334/0.531/0.196 ms
== outside it (203.0.113.5 -> 10.0.0.2)
3 packets transmitted, 0 received, 100% packet loss, time 2040ms

== siteB wg0 counters (decrypted fine, then dropped: src not in peer's allowed-ips)
    RX:  bytes packets errors dropped  missed   mcast           
           660       5      3       0       0       0 
== a route into wg0 that no peer's allowed-ips covers
PING 198.18.0.1 (198.18.0.1) 56(84) bytes of data.
From 10.0.0.1 icmp_seq=1 Destination Host Unreachable

Read it in order:

  1. From the allowed source, the ping works.
  2. From 203.0.113.5, siteA’s kernel still routes the packet into wg0, because the destination 10.0.0.2 matches peer B’s AllowedIPs. WireGuard encrypts it and siteB decrypts it successfully. Then siteB checks the inner source against peer A’s AllowedIPs (10.0.0.1/32), finds no match, and drops it. The only trace is the RX errors counter on siteB’s wg0 (3 packets, one per ping). Nothing is logged, and no ICMP message goes back.
  3. A destination that the kernel routes into wg0 but that no peer allows (198.18.0.0/15) is never sent. WireGuard rejects it at transmit, and the sender sees Destination Host Unreachable.

That makes AllowedIPs a security boundary as well as a convenience. A peer that holds a valid key still cannot inject traffic from addresses it was not given. The flip side is that every new subnet behind a peer means editing AllowedIPs on both sides: the outbound list on one end, the inbound filter on the other.

Drill 2: a PSK mismatch, seen from the wire

#!/usr/bin/env bash
# drill-psk-mismatch.sh - same keypairs, different PSK on each side: the handshake never completes
set -euo pipefail
umask 077; wg genpsk > wrong.psk
# re-adding the peer throws away its current session, forcing a new handshake
for ns in siteA siteB; do
  peer=$(ip netns exec $ns wg show wg0 peers)
  ep=$(ip netns exec $ns wg show wg0 endpoints | cut -f2)
  aips=$(ip netns exec $ns wg show wg0 allowed-ips | cut -f2 | tr ' ' ',')
  psk=ab.psk; [ $ns = siteB ] && psk=wrong.psk
  ip netns exec $ns wg set wg0 peer "$peer" remove
  ip netns exec $ns wg set wg0 peer "$peer" preshared-key $psk endpoint "$ep" allowed-ips "$aips"
done
ip netns exec siteB timeout 8 tcpdump -lnni vB -c 4 udp port 51820 2>/dev/null &
sleep 1
ip netns exec siteA ping -c 3 -W 1 10.0.0.2 | tail -2 || true
wait
echo "siteA latest-handshake (0 = never):"; ip netns exec siteA wg show wg0 latest-handshakes
echo "siteB latest-handshake (0 = never):"; ip netns exec siteB wg show wg0 latest-handshakes
$ sudo ./drill-psk-mismatch.sh
03:40:05.224460 IP 198.51.100.1.51820 > 198.51.100.2.51820: UDP, length 148
03:40:05.224668 IP 198.51.100.2.51820 > 198.51.100.1.51820: UDP, length 92
3 packets transmitted, 0 received, 100% packet loss, time 2049ms

03:40:10.317113 IP 198.51.100.1.51820 > 198.51.100.2.51820: UDP, length 148
03:40:10.317315 IP 198.51.100.2.51820 > 198.51.100.1.51820: UDP, length 92
siteA latest-handshake (0 = never):
SVXjcMNZVP6LGPEc+CYMNGGQ0/4KHY05bo34Oc5MuVg=    0
siteB latest-handshake (0 = never):
SfKQSp7pB2JRf6CnHF048gHOIul0sUi0k9lCH/V9P2I=    0

This is the most confusing failure in WireGuard, and the capture explains it:

  • The 148-byte initiation goes out, and siteB accepts it. The initiation is authenticated with the static keys and does not depend on the PSK, so siteB has no reason to be silent.
  • siteB answers with a 92-byte response. It mixes its PSK into the chaining key while building that response, so the empty AEAD box inside is sealed under a key that siteA cannot derive.
  • siteA fails to open the response and discards it without logging. About five seconds later (REKEY_TIMEOUT plus jitter) it tries again. Note the timestamps.
  • Neither side records a handshake (0 means never).

So “packets in both directions on UDP 51820, tunnel down” points to a PSK mismatch (or a corrupted PSK). A wrong public key looks different: the responder cannot authenticate the initiation, so it sends nothing back, and you see only the 148-byte packets.

Drill 3: fix and rotate the PSK live

#!/usr/bin/env bash
# drill-psk-fix.sh - put the right PSK back on siteB (live, no restart) and watch the tunnel recover
set -euo pipefail
peer=$(ip netns exec siteB wg show wg0 peers)
ip netns exec siteB wg set wg0 peer "$peer" preshared-key ab.psk
ip netns exec siteA ping -c 8 -W 1 10.0.0.2 | tail -2
now=$(date +%s)
for ns in siteA siteB; do
  echo "$ns latest handshake: $(( now - $(ip netns exec $ns wg show wg0 latest-handshakes | cut -f2) )) s ago"
done
$ sudo ./drill-psk-fix.sh
8 packets transmitted, 8 received, 0% packet loss, time 7114ms
rtt min/avg/max/mdev = 0.268/1859.476/5006.915/1836.351 ms, pipe 5
siteA latest handshake: 2 s ago
siteB latest handshake: 2 s ago
$ sudo ./lab-down.sh
lab down

wg set ... preshared-key changes the PSK on a running interface. Existing sessions keep their keys until the next handshake, and new handshakes use the new PSK. Here the first ping waited for siteA’s next retry (the 5-second maximum RTT), and after that the tunnel ran normally. To rotate a PSK in production, change both ends inside one rekey window (sessions rekey about every two minutes) so that at most one handshake attempt fails. Or let a key-exchange daemon do it for you.

Deploying it for real

  • Generate a separate PSK per peer pair with wg genpsk. One PSK shared across a whole fleet becomes a single secret whose leak removes the hedge for everyone.
  • Distribute it like a private key, over a channel you would trust in 2040: out of band, through a secret store, or from a post-quantum key exchange. A PSK pasted into a TLS-protected web form is protected only by that TLS session.
  • Store it with the private key: PresharedKey = ... under [Peer] in wg0.conf (mode 600), or PresharedKeyFile= in a systemd-networkd .netdev.
  • Monitor the handshake age. wg show wg0 latest-handshakes returns epoch seconds. Alert when the value is 0 or older than about three minutes for a peer that should be up.
  • Watch RX errors on wg0. A rising count usually means AllowedIPs is filtering traffic someone expects to pass.

Verification checklist

  • wg show lists preshared key: (hidden) on both ends for every peer that should have one
  • latest-handshakes is recent (non-zero and under about 3 minutes) on both ends
  • Capture shows both 148-byte initiations and 92-byte responses, and data packets follow
  • Each remote prefix appears in the sender’s AllowedIPs and in the receiver’s AllowedIPs for the return path
  • ip -s link show wg0 RX errors are flat during normal traffic
  • Large pings at your path MTU (ping -M do -s 1392 over IPv4 with MTU 1420) succeed

Common mistakes

Mistake Symptom Fix
PSK set on one side only, or different on each side Initiations and responses on the wire, no handshake Same wg genpsk value on both peers
Subnet added to the route but not to AllowedIPs Destination Host Unreachable from the local wg0 Add the prefix to the peer’s AllowedIPs
Subnet added on the sender, not on the receiver Silent loss, RX errors on the far wg0 Mirror AllowedIPs for the return direction
Overlapping AllowedIPs across peers Traffic goes to the “wrong” peer (longest prefix wins, and a later wg set moves the prefix) One owner per prefix; review wg show allowed-ips
Calling the PSK “post-quantum encryption” False confidence It is a symmetric hedge for recorded traffic. Distribute it safely or use a PQ key exchange
One fleet-wide PSK One leak removes the hedge everywhere One PSK per peer pair

Summary

WireGuard’s configuration is small because AllowedIPs does two jobs: it chooses the peer on the way out and filters the source on the way in, and both outcomes are silent unless you read wg show and the interface counters. The PSK adds a symmetric secret to the Noise IKpsk2 handshake. That protects recorded traffic against a future break of Curve25519, as long as the PSK itself stays secret. A PSK mismatch has a distinctive signature on the wire: both handshake messages appear and no session forms, because the responder cannot detect the mismatch and the initiator cannot open its reply. Rotate PSKs per peer pair, distribute them like private keys, and monitor handshake age.

Sources