Skip to content

feat: add ciphertext-only relay rendezvous#134

Merged
igorls merged 2 commits into
mainfrom
codex/ciphertext-relay-rendezvous
Jul 7, 2026
Merged

feat: add ciphertext-only relay rendezvous#134
igorls merged 2 commits into
mainfrom
codex/ciphertext-relay-rendezvous

Conversation

@igorls

@igorls igorls commented Jul 6, 2026

Copy link
Copy Markdown
Owner

Closes #123.

Summary

  • Add signed rendezvous registration primitives: relay-issued challenge, Ed25519 identity signature over (pubkey, endpoint, challenge), lookup storage, and per-identity rate limiting.
  • Add ciphertext-only RelayData frames that carry sender/target identity metadata plus opaque WireGuard/Noise packet bytes, accepting only WG message types 1-4.
  • Route relay frames through SWIM for forwarding and target delivery; userspace daemon paths can now receive relayed WG packets and send encrypted WG bytes direct -> punched/direct endpoint -> relay fallback.
  • Harden NAT traversal prerequisites by classifying symmetric NAT from multi-STUN observations and binding hole-punch responses/probes to the peer identity plus session token.
  • Update NAT traversal and wire-protocol docs for the new relay frame and token-bound hole-punch probes.

Validation

  • zig build --summary all
  • zig build test --summary all (132/132 tests passed)
  • zig build -Dtarget=x86_64-linux-gnu -Dno-sodium=true --summary all
  • zig build -Dtarget=aarch64-macos -Dno-sodium=true --summary all
  • zig build -Dtarget=x86_64-freebsd -Dno-sodium=true --summary all
  • WSL Ubuntu: Zig 0.16 build + build test -Dno-sodium=true (270/270 tests passed)
  • WSLC Alpine: Zig 0.16 build + build test -Dno-sodium=true (270/270 tests passed)
  • git diff --check (only existing CRLF normalization warnings)

Note: the Linux WSL/WSLC test runs still print the pre-existing unexpected errno: 9 stack traces from inert/fake UDP socket SWIM tests, but both runs finish with successful build summaries and all tests passing.

Copilot AI review requested due to automatic review settings July 6, 2026 10:22

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9f0301dc98

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/discovery/swim.zig
Comment on lines +685 to 687
if (data.len > 0 and data[0] == @intFromEnum(messages.MessageType.relay_data)) {
self.handleRelayData(data, sender_endpoint);
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Gate relay frames with the trust checks

When a node is running with enforce_trust, this early relay_data branch bypasses the isAuthorizedPeer gate below entirely; handleRelayData then forwards based only on the target being in membership, and the sender identity used for rate limiting is just unauthenticated frame bytes. An outside host that knows a target pubkey can therefore rotate fake sender pubkeys and use a trusted node as a relay for arbitrary WG-looking datagrams, so relay frames should verify the sender/target against the same trust or membership policy before forwarding.

Useful? React with 👍 / 👎.

Comment thread src/nat/relay.zig
Comment on lines +32 to +33
pub const MAX_RELAY_PAYLOAD: usize = 2048;
pub const RELAY_FRAME_HEADER_SIZE: usize = 1 + 32 + 32 + 2;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep relay frames within receive buffers

MAX_RELAY_PAYLOAD allows a valid relay datagram of 67 + 2048 = 2115 bytes, but the newly wired receive paths use 2048-byte UDP buffers in the main event loops and SwimProtocol.tick/tickReceiveOnly still use 1500-byte buffers. Those receivers will truncate any max-sized frame, and gossip-only relay nodes will also truncate ordinary 1500-byte WG transport frames once the relay header is added, causing decodeRelayData to reject them as invalid.

Useful? React with 👍 / 👎.

Comment thread src/discovery/swim.zig Outdated
Comment on lines +755 to +758
if (h.onRelayWgPacket) |cb| {
cb(h.ctx, frame.payload, frame.sender_pubkey, sender_endpoint);
} else if (h.onWgPacket) |cb| {
cb(h.ctx, frame.payload, sender_endpoint.addr, sender_endpoint.port);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve relay sender when delivering to callbacks

If a consumer only registers onWgPacket—as the current FFI handlers do—this fallback drops frame.sender_pubkey and passes the relay's UDP endpoint as if it were the peer. The callback then sends handshake responses as raw WireGuard packets back to the relay instead of wrapping them in RelayData for the original sender, so relayed handshakes in that path cannot complete; require onRelayWgPacket or implement a relay-aware fallback.

Useful? React with 👍 / 👎.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a ciphertext-only relay/rendezvous fallback to MeshGuard’s NAT traversal so symmetric-NAT peers can still form/maintain tunnels without giving the relay plaintext visibility or authorization power.

Changes:

  • Introduces RelayData (0x31) frames: sender/target identity metadata + opaque WireGuard/Noise packet bytes (types 1–4 only).
  • Improves NAT detection by classifying endpoint-dependent (symmetric) mappings using up to two STUN observations.
  • Hardens hole punching by binding responses and probes to both peer identity and a shared session token; updates SWIM routing to forward relay frames.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/protocol/messages.zig Adds RelayData message type/struct and reserves tag 0x31.
src/nat/stun.zig Adds multi-server NAT mapping classification (symmetric vs cone/public).
src/nat/relay.zig Adds relay frame codec, rendezvous registration primitives, and per-identity rate limiting.
src/nat/holepunch.zig Makes probes token-carrying and binds response/probe handling to identity + token.
src/discovery/swim.zig Adds relay frame forwarding/delivery path and relay rate limiting in SWIM.
src/main.zig Adds relay-aware send/receive handling for WG ciphertext (direct → punch → relay).
README.md Updates NAT traversal + protocol documentation to include RelayData and new behaviors.
docs/reference/modules.md Updates module map to reflect new relay/rendezvous responsibilities.
docs/concepts/wire-protocol.md Documents RelayData and token-bound holepunch probe wire formats.
docs/concepts/nat-traversal.md Documents multi-STUN classification, token-bound punching, and relay fallback.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/main.zig Outdated
const Noise = lib.wireguard.Noise;

var tun_buf: [65536]u8 = undefined;
var udp_recv_buf: [2048]u8 = undefined;
Comment thread src/main.zig Outdated
const Noise = lib.wireguard.Noise;

var tun_buf: [65536]u8 = undefined;
var udp_recv_buf: [2048]u8 = undefined;
Comment thread src/nat/relay.zig
Comment on lines +101 to +117
fn findOrInsert(self: *IdentityRateLimiter, pubkey: [32]u8, now_ns: i128) ?usize {
var empty: ?usize = null;
for (&self.entries, 0..) |*entry, i| {
if (entry.*) |e| {
if (std.mem.eql(u8, &e.pubkey, &pubkey)) return i;
} else if (empty == null) {
empty = i;
}
}
const slot = empty orelse return null;
self.entries[slot] = .{
.pubkey = pubkey,
.tokens = BURST,
.last_refill_ns = now_ns,
};
return slot;
}
Comment thread src/main.zig
Comment on lines +2735 to +2741
if (path == .direct or path == .holepunch or !relay_available) {
if (direct_ep.addr6 == null) {
tx.queue(payload, direct_ep.addr, direct_ep.port);
send_idx.* += 1;
}
return;
}
@igorls igorls merged commit e14b8b3 into main Jul 7, 2026
5 checks passed
@igorls igorls deleted the codex/ciphertext-relay-rendezvous branch July 7, 2026 11:14

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 35a403fccb

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/main.zig

if (relay_ep) |ep| {
if (ep.addr6 != null) return;
const relay_len = lib.nat.Relay.encodeRelayData(relay_buf, swim.our_pubkey, peer.identity_key, payload) catch return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Route pipeline packets through relay too

When users enable --encrypt-workers, userspaceEventLoop uses the pipeline path instead of dataPlaneWorker, so this relay wrapping is never executed; the pipeline still stages only peers with endpoint_addr[0] != 0 and flushes raw WG bytes to peer.endpoint_addr/port in flushPeerTxRing. Relay-only/symmetric peers therefore stop carrying TUN traffic in pipeline mode even though handshakes can use relay, so the relay endpoint/encoded frame needs to be carried through the pipeline send path too.

Useful? React with 👍 / 👎.

Comment thread src/main.zig
) void {
if (!swim.isAllowedRelayParticipant(frame.sender_pubkey)) return;
if (!swim.isAllowedRelayParticipant(frame.target_pubkey)) return;
if (!swim.relay_limiter.allow(frame.sender_pubkey, nowAwakeNs())) return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Rate-limit relayed packets by authenticated peer

Fresh evidence: even after the new participant checks, this target-side path consumes the bucket for unauthenticated frame.sender_pubkey before the WG payload is authenticated, and the decrypted identity from decryptTransport or the handshake slot is never compared with it. In an enforce_trust mesh, any admitted peer can wrap its own valid WG packets while naming another admitted peer, draining that peer's relay quota or rotating labels to bypass its own per-identity limit; reject mismatches or rate-limit by the authenticated WG identity after successful decrypt/handshake.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Ciphertext-only rendezvous + relay for WAN meshes (closes the symmetric-NAT gap; subsumes C1/C2)

2 participants