14 KiB
UDP Nostr
Send a Nostr event as a single UDP datagram — no handshake, no connection, no state.
The Problem: How Authoritarian Regimes Block the Internet
Authoritarian regimes (such as those operating national firewalls) use four layers of techniques to control what their 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 regime'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. Authoritarian regimes have 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. The table below combines the DPI fingerprint each regime targets, the blocking technique used, and the broader landscape of censorship-resistant protocols — their transport, key technique, resistance level, and real-world adoption.
| Protocol | Transport | What the regime looks for / Key technique | How it blocks | Censorship resistance | Adoption |
|---|---|---|---|---|---|
| HTTP | TCP port 80 | Host: header with blocked domain |
TCP RST injection — fake reset to both sides, killing the connection | None (plaintext) | Universal |
| TLS | TCP port 443 | SNI in the ClientHello — domain name in plaintext |
TCP RST injection if SNI matches a blocked domain | Low (SNI leaks) | Universal |
| Tor | TCP (any port) | Tor TLS handshake fingerprint (cert, cipher suites, packet sizes) | TCP RST injection. Very effective at blocking bridges. | Medium (obfs4 helps) | High (Tor Browser) |
| OpenVPN | TCP/UDP | OpenVPN TLS handshake | TCP RST injection | Low (fingerprintable) | High (VPN default) |
| Shadowsocks | TCP (any port) | Random-looking traffic, harder to fingerprint | Active probing (see Layer 4) | Medium | Medium |
| QUIC / HTTP3 | UDP port 443 | Initial packet has recognizable structure (version, connection ID); encrypted by default, looks like random bytes | Can block entirely or selectively. Some regimes tried but it broke too many Google services. | High (accidental) | Very high (30–50% of web traffic) |
| WireGuard | UDP (any port) | Minimal handshake, hard to fingerprint; no visible handshake | Active probing — send a handshake initiation and see if the server responds | Medium (accidental) | Growing (Linux kernel, VPNs) |
| obfs4 | TCP (any port) | Random-looking traffic, IAT obfuscation | Active probing — connect and check for valid obfs4 handshake | High (intentional) | High (Tor Browser default) |
| CurveCP | UDP | Single-packet handshake, minimal design | — (no deployment to target) | High (intentional) | None (superseded by QUIC) |
| DNS tunneling | UDP port 53 | Hide data in essential infrastructure | Statistical analysis of query patterns | Medium (detectable) | Low (malware, circumvention) |
| uTP / BitTorrent | UDP | Delay-based congestion control, no handshake | ISP throttling by traffic analysis | Low (evades throttling) | High (BitTorrent default) |
| IP fragmentation | IP | Hide data in fragments beyond the first | Most modern DPI reassembles; older systems miss later fragments | Low (known to defenders) | N/A (attack technique) |
| tcpcrypt | TCP | Encrypt TCP handshake transparently | — (not deployed) | Low (not deployed) | None |
| UDP Nostr | UDP (any port) | No handshake, no fingerprint — signed bytes in one datagram under 1472 bytes | Nothing to detect. No SYN, no SNI, no ClientHello, no key exchange. Relay doesn't respond to invalid events. | High (intentional) | In development (this repo) |
The TCP RST injection technique is a signature move of national firewalls. 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 regime 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 regime'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 adversary 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
| Regime 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 adversary 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 | No major regime has done this because it would break the internet for hundreds of millions of 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 src/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 src/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 src/udp_nostr_send.py laantungir.net 8889
The complete programs
src/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))
src/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 docs/max_single_packet_event.md 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.