Files
udp_nostr/README.md
T
2026-08-13 05:42:08 -04:00

14 KiB
Raw Blame History

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 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 (3050% 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:

  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 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.