Laan Tungir 359534ee12 Initial commit: UDP Nostr — send Nostr events as single UDP datagrams
- Minimal sender/receiver (4 lines of Python each)
- nak + nc zero-code demo approach
- GFW analysis: how each layer fails against UDP Nostr
- Protocol hardening, MTU exploration, no-handshake analysis
- Binary event format (.bne) spec
- Relay integration plan
2026-08-12 07:53:01 -04:00

UDP Nostr

Send a Nostr event as a single UDP datagram — no handshake, no connection, no state. The most unstoppable way to post content ever invented.


The Problem: How the Great Firewall Works

China's Great Firewall (GFW) uses four layers of techniques to control what its citizens can access:

Layer 1: IP Blocking

Block entire IP ranges at border routers. Any packet to/from blocked IPs is dropped.

Bypass: Use a different IP (proxy, VPN, CDN). Services behind Cloudflare often work because the IP is shared with legitimate traffic.

Layer 2: DNS Poisoning

When you resolve twitter.com, the GFW's DNS interceptors return a fake IP or drop the response. The browser gets nowhere.

Bypass: Use DNS-over-HTTPS (DoH), DNS-over-TLS (DoT), or a foreign DNS resolver. The GFW has started blocking these too.

Layer 3: Deep Packet Inspection (DPI) + TCP RST Injection

Border routers inspect packet contents in real time and look for protocol fingerprints:

Protocol What the GFW looks for How it blocks
HTTP Host: header with blocked domain TCP RST injection — sends a fake reset packet to both sides, killing the connection
TLS SNI (Server Name Indication) in the ClientHello — the domain name is in plaintext TCP RST injection if the SNI matches a blocked domain
Tor Recognizes the Tor TLS handshake fingerprint (certificate, cipher suites, packet sizes) TCP RST injection. Very effective at blocking Tor bridges.
OpenVPN Recognizes the OpenVPN TLS handshake TCP RST injection
Shadowsocks Random-looking traffic, harder to fingerprint Active probing (see Layer 4)
QUIC Initial packet has recognizable structure (version, connection ID) Can block QUIC entirely or selectively. China tried but it broke too many Google services.
WireGuard Minimal handshake, hard to fingerprint Active probing — send a handshake initiation and see if the server responds

The TCP RST injection technique is the GFW's signature move. It doesn't just block at the router — it impersonates both sides of the connection and sends fake TCP RST packets to kill it. This is why connections sometimes start (you see the first bytes) and then die.

Layer 4: Active Probing

For protocols that don't have a visible handshake (Shadowsocks, obfs4, custom VPNs), the GFW doesn't just inspect passively — it actively connects to suspected servers and tries to complete a handshake. If the server responds with a valid protocol handshake, the IP is added to the blocklist.

This is the most sophisticated layer — and the most relevant to this project.


The Key Asymmetry

The GFW's most powerful tool — active probing — works by detecting protocol handshakes. Every circumvention tool (VPN, Tor, Shadowsocks) has a handshake that can be detected. The GFW connects to the server, sees the handshake, and blocks the IP.

UDP Nostr has no handshake. The adversary can send a probe, but the relay's response to an invalid event is indistinguishable from a server that isn't running Nostr at all. The adversary cannot tell if the relay is a Nostr relay or just a random UDP service.

This is the fundamental advantage. Every other circumvention tool is playing a cat-and-mouse game of obfuscating its handshake. UDP Nostr eliminates the handshake entirely.

How UDP Nostr Defeats Each Layer

GFW technique Can it block UDP Nostr? Why
IP blocking Yes — but only if they know the relay's IP The relay can move. Blocking one IP doesn't block the protocol.
DNS poisoning No The sender doesn't need DNS. Send the UDP packet to the IP directly.
DPI (passive) No No protocol fingerprint. No handshake, no SNI, no TLS ClientHello, no recognizable structure. Just bytes.
TCP RST injection No RST is a TCP control packet. UDP has no RST. The GFW cannot kill a UDP "connection" because there is no connection.
Active probing No The adversary sends a fake event. The relay tries to verify the signature, fails, and drops the packet. No handshake to detect. The relay's response to an invalid event looks the same as random noise.
Block all UDP Yes — but this breaks DNS, QUIC/HTTP3, VoIP, video calls, gaming, and more China has not done this because it would break the internet for 1.4 billion people.

Quick Start: nak + Python (4 lines each)

The sender reads a signed Nostr event from stdin and sends it as one UDP datagram. The receiver listens and prints the JSON.

Terminal 1 — receiver:

python3 udp_nostr_recv.py 0.0.0.0 8889

Terminal 2 — sender:

nak event -k 1 -c "Hello via UDP Nostr!" --sec $(nak key generate) \
  | python3 udp_nostr_send.py 127.0.0.1 8889

Or send to a remote server:

nak event -k 1 -c "Hello from local to laantungir.net via UDP!" --sec $(nak key generate) \
  | python3 udp_nostr_send.py laantungir.net 8889

The complete programs

udp_nostr_send.py — 4 lines of code:

import socket, sys
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.sendto(sys.stdin.read().encode(), (sys.argv[1], int(sys.argv[2]) if len(sys.argv) > 2 else 8888))

udp_nostr_recv.py — 4 lines of code:

import socket, sys
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind((sys.argv[1] if len(sys.argv) > 1 else "0.0.0.0", int(sys.argv[2]) if len(sys.argv) > 2 else 8888))
while True:
    data, addr = sock.recvfrom(65535)
    print(f"{len(data)} bytes from {addr[0]}:{addr[1]}")
    print(data.decode())
    print()

What the receiver prints

375 bytes from 146.70.187.194:48422
{"kind":1,"id":"68b792a2dc969a7c44442f8d0c8ea4136e334e13294e0ca7e2706ebf1164d9e2","pubkey":"164e87cc5f5fa994bb345cc5c86ed526c433df49be10951c4e1927889859602d","created_at":1786534043,"tags":[],"content":"Minimal demo over real internet!","sig":"b28061b058346e75fcf45f82942b9eb500f4411b4ab4d91e0a4d344ae57c502085dcc830d42449e26a7952ec731ada10ff92fd8708bbf6f0b8460c72197416d1"}

Quick Start: nak + nc (zero custom code)

For an even simpler demo, use nc (netcat) — available on virtually every Linux machine. No Python needed at all.

Terminal 1 — receiver:

nc -u -l 8889

Terminal 2 — sender:

nak event -k 1 -c "Hello via nc -u!" --sec $(nak key generate) \
  | nc -u -w1 127.0.0.1 8889

The -u flag tells nc to use UDP. -l makes it listen. -w1 closes the sender after 1 second of inactivity (otherwise nc hangs waiting for more stdin).

The receiver prints the raw Nostr event JSON:

{"kind":1,"id":"1d43fe396e9c48b0f6c95290365e3fcf6e490e85a023a80f87ec9845d1cef838","pubkey":"205abcb7aa5ea9c6e9d09dbf9a5f50ed619f510d52888060e4ea9caaac310666","created_at":1786534242,"tags":[],"content":"Hello via nc -u!","sig":"1fbee9529194f43749b15ff5c75c11b541b06d04c799daf89473f5a3ba864a35f3ad63e0537b79bd468cadabf5d2fe26762ef1066ee69e30ed012c6fe344ac32"}

nc vs Python: the single-packet guarantee

Tool Send DF flag Single-packet guarantee
nc -u No — best effort only
Python (4 lines) Yes — EMSGSIZE if it would fragment

nc has two limitations:

  1. No DF (Don't Fragment) flagnc doesn't expose setsockopt(), so you can't set IP_MTU_DISCOVER. Without DF, the kernel may silently fragment the packet and neither side knows.

  2. No guarantee of single datagramnc reads stdin in chunks and sends each chunk as a separate UDP datagram. In practice, since nak event outputs one line and closes, nc usually reads it all at once and sends one datagram. But this is not guaranteed.

The Python sender exists specifically because it can call sock.setsockopt(socket.IPPROTO_IP, 10, 2) to set the DF flag — the only way to prove single-packet delivery. For a quick demo, nc works fine. For the actual protocol guarantee, you need the Python sender.


What This Demonstrates

  1. No handshake — The sender creates and sends the event in one shot. No prior communication with the receiver. No SYN, no TLS ClientHello, no key exchange.
  2. Self-validating — The event carries its own proof of authorship (the signature). The receiver validates using only the pubkey in the event. No shared secrets, no session setup.
  3. Single datagram — The entire event fits in one UDP packet (under 1472 bytes). This means it cannot be blocked without blocking all UDP traffic.
  4. Stateless — The receiver does not store any state about the sender. It receives, prints, and forgets.
  5. No dependenciesnak + nc (zero custom code) or nak + 4 lines of Python.

The Key Insight

Compare this to any TCP-based protocol:

Protocol Handshake Round trips before data
HTTP/1.1 TCP SYN + TLS 3 (SYN, SYN-ACK, ACK + ClientHello, ServerHello+Cert, Finished)
HTTP/3 (QUIC) QUIC handshake 1 (but still requires response)
Nostr over UDP None 0 — fire and forget

The Nostr event is already signed before it touches the network. The signature replaces the handshake.

Maximum Event Size: JSON vs. Binary (.bne)

A single UDP datagram can carry 1472 bytes of payload (1500 Ethernet MTU - 20 IP header - 8 UDP header). Here's how the two formats compare for a kind 1 event with empty tags:

Component JSON .bne Binary
id 64 hex chars (computed from SHA256 hash — not on wire)
pubkey 64 hex chars 32 raw bytes
created_at 10 digit chars 8 bytes (uint64)
kind 1 digit char 2 bytes (uint16)
tags (empty) 2 chars [] 2 bytes (count=0)
content_length (implicit from JSON) 4 bytes (uint32)
sig 128 hex chars 64 raw bytes
JSON structural chars 68 bytes ({"id":"...","pubkey":"..."...}) 0
Fixed overhead 337 bytes 113 bytes
Available for content 1135 bytes 1359 bytes
English words (~5.5 chars/word) ~206 words ~247 words

The .bne binary format gives 224 more bytes (20% more) of content per datagram. The savings come from:

  • No hex encoding: pubkey 64→32 bytes, sig 128→64 bytes (96 bytes saved)
  • No id on the wire: computed from SHA256 hash of the event (64 bytes saved)
  • No JSON structural overhead: no quotes, commas, colons, braces (68 bytes saved)

See max_single_packet_event.md in rethinking_nostr/ for the full .bne byte layout.

Single-Packet Guarantee

How the Sender Enforces It

The sender can set the Don't Fragment (DF) flag on the IP header:

sock.setsockopt(socket.IPPROTO_IP, 10, 2)  # IP_MTU_DISCOVER = DO

With DF set, the kernel checks the path MTU before sending. If the packet is larger than the path MTU, sendto() raises OSError (EMSGSIZE) and the sender knows immediately that the packet would have been fragmented. Without this flag, the IP layer fragments silently and neither sender nor receiver can tell.

What the Receiver Can Know

The receiver cannot directly observe IP-level fragmentation. The OS reassembles fragments before delivering to recvfrom(). The receiver only sees the complete reassembled datagram. The size check is a heuristic — if the datagram is ≤ 1472 bytes, it could have been sent as one packet, but the receiver cannot prove it.

The Asymmetry

Role Can they know? How?
Sender Yes, definitively DF flag → EMSGSIZE if fragmentation needed
Receiver No, not definitively OS hides fragmentation; can only check size
Adversary (on-path) Yes Can observe IP fragments directly
Adversary (off-path) No Cannot see the packet at all

The sender is the only party that can enforce single-packet delivery. The receiver can only infer it. This is why the DF flag is critical — it moves the guarantee from "probably" to "provably."

What About the OS?

  • Linux: IP_MTU_DISCOVER with IP_PMTUDISC_DO sets DF and performs path MTU discovery
  • macOS/BSD: IP_DONTFRAG socket option (different constant, same effect)
  • Windows: setsockopt with IP_DONTFRAGMENT (requires WinSock)
Document Description
protocol_hardening.md The core idea: design protocols so interference requires violating physics or OS guarantees. Explains the MTU boundary, substrate constraints, and the adversary's dilemma.
mtu_exploration.md Survey of 10 projects/people who have worked on MTU exploitation for censorship resistance: QUIC, CurveCP (djb), obfs4 (Tor), DNS tunneling, uTP (BitTorrent), WireGuard, CoAP/DTLS, binary events, IP fragmentation, tcpcrypt.
no_handshake.md Analysis of the no-handshake property: why Nostr's self-validating events are fundamentally different from every protocol that announces itself with a handshake pattern.
udp_nostr_send.py Sender: reads signed event JSON from stdin (pipe from nak), sends as single UDP datagram.
udp_nostr_recv.py Receiver: listens for UDP datagrams, prints the JSON.
udp_nostr_send.js Sender (JavaScript/Node.js): same logic using the built-in dgram module.
udp_nostr_recv.js Receiver (JavaScript/Node.js): same logic using the built-in dgram module.
S
Description
Send Nostr events as single UDP datagrams — no handshake, no connection, no state.
Readme
160 KiB
Languages
JavaScript 84.8%
Python 8.2%
Shell 7%