Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,57 @@
All notable changes to this project will be documented in
this file. Format based on [Keep a Changelog](https://keepachangelog.com/).

## [0.2.1] - unreleased

### Added — wg-relay hardening

- **WG-shape filter at XDP**: drops packets whose first byte
isn't a WireGuard message type (1/2/3/4) or whose length
doesn't match the type. Fires before the source-IP lookup,
so non-WG noise on the relay's port stops at the NIC and
doesn't pollute `drop_unknown_src`. New counter
`drop_not_wg_shaped`.
- **MAC1 verification for handshakes**: when both ends of a
link have `wg peer pubkey` stamped, every handshake init
/ response from a registered peer is verified against the
partner's pubkey via Blake2s-keyed MAC1. Mismatch drops
with `drop_handshake_pubkey_mismatch`. Catches misconfigured
clients (wrong relay, NAT collisions, stale endpoint reuse).
Engages only when the partner pubkey is set, so existing
operators keep today's behaviour exactly.
- **Automatic peer roaming** (`mode: wireguard`): a peer's
endpoint auto-updates when their IP changes. Handshake from
an unknown source is matched against every partner's pubkey
via MAC1 to identify which peer it's from; a candidate
endpoint is registered. The committed endpoint stays put
until transport-data from the candidate confirms the roam,
at which point the new endpoint is committed, the BPF map
is refreshed (XDP fast path picks up the new endpoint), and
the roster is persisted. New per-peer counter
`endpoint_relearn`. Forged handshakes (attacker who knows
the pubkey but lacks the private key) tick
`drop_relearn_unconfirmed` and never commit.
- **Dynamic source-IP blocklist**: source IPs that produce
repeated failed-confirm strikes (forged handshakes that
never progressed to transport data) escalate onto a BPF
blocklist. Defaults: 2 strikes / 60 s → 60 s block;
5 / 1 h → 1 h block; 10 / 24 h → 24 h block. Blocked
sources drop at the top of XDP. Closes the relay-as-
anonymizer attack against the partner. New verb
`wg blocklist list`. New counters `drop_blocklisted` (XDP)
and per-IP strike records.
- **Endpoint-hijack defense**: a forged handshake init must receive a partner-attributable response (the partner's type-2 `receiver_index` matches the init's `sender_index`) before the candidate slot is allowed to confirm. A forger who can pass MAC1 but lacks the static-key handshake can no longer bounce the candidate to confirm by sending a matching-shaped transport-data packet of their own.
- **Type-2 from unknown source dropped outright**: legitimate handshake responses come from the committed responder endpoint, so an unknown-source type-2 has no place in the protocol and was an unauthenticated amplifier surface.
- **Retry-init forward rate-limit**: while a candidate is unconfirmed, the no-op-forward branch caps retry forwards at one per second per source. Legitimate `wg.ko` retries every 5 s; a flood of forged retries is clamped and then strikes into the blocklist.
- **Strike-table sweep**: stale strike entries (older than the widest policy window) are pruned during candidate expiry so spoofed-source one-shot strikes can't grow the table without bound.

### Added — Crypto

- **Standalone Blake2s** in `src/crypto/`. RFC 7693 reference
port; libsodium ships Blake2b only and WireGuard's MAC1
uses Blake2s specifically. Verified against the published
"abc" / empty-input vectors.

## [0.2.0] - 2026-04-26

### Added — WireGuard Relay Mode
Expand Down
2 changes: 1 addition & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ endif()
project(hyper-derp
LANGUAGES C CXX
DESCRIPTION "High-performance DERP relay server"
VERSION 0.2.0
VERSION 0.2.1
)

set(CMAKE_CXX_STANDARD 23)
Expand Down
102 changes: 96 additions & 6 deletions bpf/wg_relay.bpf.c
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,14 @@
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_endian.h>

// Stats indices.
#define STAT_RX 0
#define STAT_FWD 1
#define STAT_PASS_NO_PEER 2
#define STAT_PASS_NO_MAC 3
// Stats indices. Keep in sync with WgXdpStats in
// include/hyper_derp/wg_relay.h.
#define STAT_RX 0
#define STAT_FWD 1
#define STAT_PASS_NO_PEER 2
#define STAT_PASS_NO_MAC 3
#define STAT_DROP_NOT_WG_SHAPED 4
#define STAT_DROP_BLOCKLISTED 5

// -- Map types --------------------------------------------

Expand Down Expand Up @@ -91,7 +94,7 @@ struct {

struct {
__uint(type, BPF_MAP_TYPE_PERCPU_ARRAY);
__uint(max_entries, 4);
__uint(max_entries, 8);
__type(key, __u32);
__type(value, __u64);
} wg_xdp_stats SEC(".maps");
Expand Down Expand Up @@ -140,6 +143,26 @@ struct {
__type(value, __u32); // ipv4 in network byte order
} wg_nic_ips SEC(".maps");

// Blocklist for source IPs that produced repeated failed
// candidate confirmations (i.e. forged handshakes — they had
// the partner's pubkey but couldn't progress to transport
// data because they don't have the static private key).
// Userspace populates expiry_ns from CLOCK_MONOTONIC; the
// BPF program compares against bpf_ktime_get_ns(). An
// entry whose expiry has passed is treated as not present —
// userspace sweeps the map periodically but a stale-but-
// expired entry is harmless either way.
struct blocklist_entry {
__u64 expiry_ns; // monotonic ns; 0 = no longer active
};

struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 8192);
__type(key, __u32); // IPv4 src in network byte order
__type(value, struct blocklist_entry);
} wg_blocklist SEC(".maps");

// -- Helpers ----------------------------------------------

static __always_inline void
Expand Down Expand Up @@ -205,6 +228,73 @@ int wg_relay_xdp(struct xdp_md *ctx)

inc_stat(STAT_RX);

// Dynamic blocklist — drop sources that produced
// repeated failed candidate confirmations. Stale
// entries (expiry in the past) fall through.
{
__u32 src_ip = ip->saddr;
struct blocklist_entry *bl =
bpf_map_lookup_elem(&wg_blocklist, &src_ip);
if (bl && bl->expiry_ns > bpf_ktime_get_ns()) {
inc_stat(STAT_DROP_BLOCKLISTED);
return XDP_DROP;
}
}

// WG-shape filter — peek at the first byte of the UDP
// payload and verify it's a WireGuard message type
// (1 init, 2 response, 3 cookie, 4 transport). Anything
// else is either malformed or a non-WG client that ended
// up at the relay's port; dropping at XDP keeps it off
// the forward path entirely so the partner never has to
// process it. Length sanity covers the fixed-size types;
// transport-data has variable length capped by MTU.
__u8 *wg = (void *)(udp + 1);
if ((void *)(wg + 1) > data_end) {
inc_stat(STAT_DROP_NOT_WG_SHAPED);
return XDP_DROP;
}
__u16 udp_payload_len =
bpf_ntohs(udp->len) - sizeof(struct udphdr);
__u8 wg_type = wg[0];
if (wg_type == 1) {
if (udp_payload_len != 148) {
inc_stat(STAT_DROP_NOT_WG_SHAPED);
return XDP_DROP;
}
} else if (wg_type == 2) {
if (udp_payload_len != 92) {
inc_stat(STAT_DROP_NOT_WG_SHAPED);
return XDP_DROP;
}
} else if (wg_type == 3) {
if (udp_payload_len != 64) {
inc_stat(STAT_DROP_NOT_WG_SHAPED);
return XDP_DROP;
}
} else if (wg_type == 4) {
// Transport data: header (16 B) + counter (8 B) +
// at least the AEAD tag (16 B).
if (udp_payload_len < 32) {
inc_stat(STAT_DROP_NOT_WG_SHAPED);
return XDP_DROP;
}
} else {
inc_stat(STAT_DROP_NOT_WG_SHAPED);
return XDP_DROP;
}

// Hand handshake init (1) and response (2) packets up
// to userspace: it owns the MAC1 verification + the
// candidate-then-confirm roaming flow, neither of which
// fits the XDP verifier comfortably and both of which
// are rare enough (one handshake per session per ~25 s)
// that the userspace round trip is free. Cookie reply
// (3) and transport data (4) keep the XDP fast path.
if (wg_type == 1 || wg_type == 2) {
return XDP_PASS;
}

// Look up the source endpoint in the peer map. Miss
// means either an unregistered peer or one whose
// source IP/port doesn't match the operator's pin —
Expand Down
6 changes: 6 additions & 0 deletions cmake/libderp.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ add_library(libderp_obj OBJECT
src/einheit_protocol.cc
src/einheit_channel.cc
src/wg_relay.cc
src/crypto/blake2s.cc
)
target_include_directories(libderp_obj PUBLIC
${PROJECT_SOURCE_DIR}/include
Expand All @@ -53,6 +54,11 @@ target_include_directories(libderp_obj PUBLIC
${BPF_INCLUDE_DIR}
${ZMQ_INCLUDE_DIR}
)
target_include_directories(libderp_obj PRIVATE
# Internal-only headers (not part of the public include/
# tree) — e.g. src/crypto/blake2s.h.
${PROJECT_SOURCE_DIR}/src
)
target_link_libraries(libderp_obj PUBLIC
${URING_LIB}
${SODIUM_LIB}
Expand Down
50 changes: 50 additions & 0 deletions dist/release-notes/v0.2.1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
## What's new

Hardening for `mode: wireguard` plus **automatic peer roaming**.

### Automatic roaming

When a WG peer's IP changes — laptop changes networks, a CGNAT rebind, the home ISP renewed the DHCP lease — their tunnel through the relay used to break until an operator manually updated the roster. Now the relay recognises the peer's next handshake from the new IP via MAC1 verification against the link partner's stamped pubkey, candidate-registers the new endpoint, mirrors the partner's response to the candidate so the handshake completes, and commits the new endpoint once transport-data confirms the roam. Operator does nothing; tunnel comes back on its own.

The "tentative-then-confirm" gate makes this safe: an attacker who knows the partner's pubkey can forge a handshake init, but they can't progress to transport-data without the static private key, so the candidate expires uncommitted and the original endpoint stays put.

### Dynamic blocklist

Repeated failed-confirm attempts from the same source IP (i.e. forged handshakes from someone who has the pubkey but not the keys) escalate onto a BPF blocklist:

- 2 strikes / 60 s → 60 s block
- 5 strikes / 1 h → 1 h block
- 10 strikes / 24 h → 24 h block

Blocked sources drop at the top of XDP — they can't even reach the forward path, so the relay stops being the anonymization layer for the attacker. New `wg blocklist list` shows what's currently blocked.

### Other hardening

- **WG-shape filter** at XDP — drops UDP/51820 packets whose first byte isn't a WireGuard message type. Stops non-WG noise (port scans, misdirected clients) from polluting counters.
- **MAC1 verification on handshakes from registered sources** when both ends have a stamped pubkey — catches misconfigured clients pointing at the wrong relay.

### New counters in `wg show`

- `drop_not_wg_shaped`
- `drop_handshake_pubkey_mismatch`
- `drop_handshake_no_pubkey_match`
- `drop_relearn_unconfirmed`
- `xdp_drop_blocklisted`

A non-zero `drop_relearn_unconfirmed` is the canonical "someone is forging handshakes" signal.

## Install

```bash
sudo apt update && sudo apt install hyper-derp
```

(If you haven't added the repo: see the [v0.2.0 install instructions](https://github.com/hyper-derp/Hyper-DERP/releases/tag/v0.2.0).)

## Compatibility

- All 0.2.0 behaviour is unchanged unless you stamp pubkeys via `wg peer pubkey` — the new MAC1 path engages only for links with both ends' pubkeys on file.
- No CLI verb removals. New verb: `wg blocklist list`.
- Roster format extended with new optional per-peer fields (`endpoint_relearn`); old rosters load unchanged.

Full changelog: [CHANGELOG.md](https://github.com/hyper-derp/Hyper-DERP/blob/v0.2.x/CHANGELOG.md) · Design notes: [docs/design/wg_relay_pubkey_filter.md](https://github.com/hyper-derp/Hyper-DERP/blob/v0.2.x/docs/design/wg_relay_pubkey_filter.md), [docs/design/wg_relay_hardening.md](https://github.com/hyper-derp/Hyper-DERP/blob/v0.2.x/docs/design/wg_relay_hardening.md)
Loading
Loading