- 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
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:
-
No DF (Don't Fragment) flag —
ncdoesn't exposesetsockopt(), so you can't setIP_MTU_DISCOVER. Without DF, the kernel may silently fragment the packet and neither side knows. -
No guarantee of single datagram —
ncreads stdin in chunks and sends each chunk as a separate UDP datagram. In practice, sincenak eventoutputs one line and closes,ncusually 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
- 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.
- 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.
- Single datagram — The entire event fits in one UDP packet (under 1472 bytes). This means it cannot be blocked without blocking all UDP traffic.
- Stateless — The receiver does not store any state about the sender. It receives, prints, and forgets.
- No dependencies —
nak+nc(zero custom code) ornak+ 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:
pubkey64→32 bytes,sig128→64 bytes (96 bytes saved) - No
idon 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_DISCOVERwithIP_PMTUDISC_DOsets DF and performs path MTU discovery - macOS/BSD:
IP_DONTFRAGsocket option (different constant, same effect) - Windows:
setsockoptwithIP_DONTFRAGMENT(requires WinSock)
Related Documents in This Directory
| 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. |