This commit is contained in:
Laan Tungir
2026-08-12 16:34:22 -04:00
parent cfe9293b7f
commit 9f7145b65c
20 changed files with 1171 additions and 687 deletions
+60 -34
View File
@@ -4,9 +4,9 @@ Send a Nostr event as a single UDP datagram — no handshake, no connection, no
---
## The Problem: How the Great Firewall Works
## The Problem: How Authoritarian Regimes Block the Internet
China's Great Firewall (GFW) uses four layers of techniques to control what its citizens can access:
Authoritarian regimes (such as those operating national firewalls) use four layers of techniques to control what their citizens can access:
### Layer 1: IP Blocking
@@ -16,29 +16,36 @@ Block entire IP ranges at border routers. Any packet to/from blocked IPs is drop
### 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.
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. The GFW has started blocking these too.
**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**:
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 | 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 |
| 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 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.
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 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.
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.
@@ -46,7 +53,7 @@ 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.
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.
@@ -54,14 +61,14 @@ This is the fundamental advantage. Every other circumvention tool is playing a c
### How UDP Nostr Defeats Each Layer
| GFW technique | Can it block UDP Nostr? | Why |
| 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 GFW cannot kill a UDP "connection" because there is no connection. |
| **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 | China has not done this because it would break the internet for 1.4 billion people. |
| **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. |
---
@@ -71,31 +78,31 @@ The sender reads a signed Nostr event from stdin and sends it as one UDP datagra
**Terminal 1 — receiver:**
```bash
python3 udp_nostr_recv.py 0.0.0.0 8889
python3 src/udp_nostr_recv.py 0.0.0.0 8889
```
**Terminal 2 — sender:**
```bash
nak event -k 1 -c "Hello via UDP Nostr!" --sec $(nak key generate) \
| python3 udp_nostr_send.py 127.0.0.1 8889
| python3 src/udp_nostr_send.py 127.0.0.1 8889
```
Or send to a remote server:
```bash
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
| python3 src/udp_nostr_send.py laantungir.net 8889
```
### The complete programs
**`udp_nostr_send.py`** — 4 lines of code:
**[`src/udp_nostr_send.py`](src/udp_nostr_send.py)** — 4 lines of code:
```python
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:
**[`src/udp_nostr_recv.py`](src/udp_nostr_recv.py)** — 4 lines of code:
```python
import socket, sys
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
@@ -199,7 +206,7 @@ The `.bne` binary format gives **224 more bytes (20% more)** of content per data
- **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`](max_single_packet_event.md) for the full `.bne` byte layout.
See [`docs/max_single_packet_event.md`](docs/max_single_packet_event.md) for the full `.bne` byte layout.
## Single-Packet Guarantee
@@ -234,14 +241,33 @@ The sender is the only party that can **enforce** single-packet delivery. The re
- **macOS/BSD**: `IP_DONTFRAG` socket option (different constant, same effect)
- **Windows**: `setsockopt` with `IP_DONTFRAGMENT` (requires WinSock)
## Related Documents in This Directory
## Related Documents
### Design & analysis — [`docs/`](docs/)
| Document | Description |
|---|---|
| [`protocol_hardening.md`](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`](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`](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`](udp_nostr_send.py) | Sender: reads signed event JSON from stdin (pipe from `nak`), sends as single UDP datagram. |
| [`udp_nostr_recv.py`](udp_nostr_recv.py) | Receiver: listens for UDP datagrams, prints the JSON. |
| [`udp_nostr_send.js`](udp_nostr_send.js) | Sender (JavaScript/Node.js): same logic using the built-in `dgram` module. |
| [`udp_nostr_recv.js`](udp_nostr_recv.js) | Receiver (JavaScript/Node.js): same logic using the built-in `dgram` module. |
| [`docs/protocol_hardening.md`](docs/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. |
| [`docs/mtu_exploration.md`](docs/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. |
| [`docs/no_handshake.md`](docs/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. |
| [`docs/binary_events.md`](docs/binary_events.md) | The `.bne` binary event format: byte layout, encoding, and size savings vs JSON. |
| [`docs/max_single_packet_event.md`](docs/max_single_packet_event.md) | Maximum single-packet event sizing and the full `.bne` byte layout. |
| [`docs/passive_sniffing_relay.md`](docs/passive_sniffing_relay.md) | Plausible-deniability relay models: passive sniffing and authoritative-DNS reception. |
| [`docs/TCP_vs_UDP.md`](docs/TCP_vs_UDP.md) | TCP vs UDP comparison in the context of censorship resistance. |
| [`docs/udp_nostr_relay_plan.md`](docs/udp_nostr_relay_plan.md) | Design plan for a UDP Nostr relay. |
### Programs — [`src/`](src/)
| File | Description |
|---|---|
| [`src/udp_nostr_send.py`](src/udp_nostr_send.py) | Sender: reads signed event JSON from stdin (pipe from `nak`), sends as single UDP datagram. |
| [`src/udp_nostr_recv.py`](src/udp_nostr_recv.py) | Receiver: listens for UDP datagrams, prints the JSON. |
| [`src/udp_nostr_send.js`](src/udp_nostr_send.js) | Sender (JavaScript/Node.js): same logic using the built-in `dgram` module. |
| [`src/udp_nostr_recv.js`](src/udp_nostr_recv.js) | Receiver (JavaScript/Node.js): same logic using the built-in `dgram` module. |
| [`src/test_local.sh`](src/test_local.sh) | Local loopback test script. |
### Plans — [`plans/`](plans/)
| Document | Description |
|---|---|
| [`plans/udp_nostr_fips_discovery.md`](plans/udp_nostr_fips_discovery.md) | Plan: combine UDP Nostr with FIPS Nostr-mediated endpoint discovery (relay mobility without a data-plane handshake). |
-652
View File
@@ -1,652 +0,0 @@
# Authoritative DNS Relay — Receiving Nostr Events via DNS Queries
## The Core Idea
Run an authoritative DNS server for a domain you control. A sender crafts a DNS query for a subdomain containing an encoded Nostr event. The global DNS system automatically routes the query to your server, where you extract the event and inject it into your relay.
```
Sender → Sender's DNS resolver (8.8.8.8, 1.1.1.1, ISP, etc.)
↓ "What is the IP of <base64_event>.yourdomain.com?"
Root DNS servers → TLD servers
↓ "yourdomain.com is managed by ns1.yourdomain.com at 1.2.3.4"
Sender's DNS resolver
↓ "Hey 1.2.3.4, what is the IP of <base64_event>.yourdomain.com?"
Your authoritative nameserver (1.2.3.4) ← YOU RECEIVE THE QUERY HERE
↓ "I don't have that record. NXDOMAIN."
Sender's DNS resolver → Sender
```
**You do NOT need to control the sender's DNS resolver.** The global DNS system automatically routes the query to your authoritative nameserver, regardless of which resolver the sender uses. The sender just types a domain name.
---
## Why This Matters
### The Problem It Solves
In the standard UDP Nostr model, the sender addresses a packet directly to the relay:
```
Sender → UDP datagram → Relay IP:Port
```
An observer on the network path sees the relay's IP and can identify the communication. In the authoritative DNS model:
```
Sender → DNS query → yourdomain.com (via standard DNS resolution)
```
An observer sees a DNS query for your domain. The content is hidden in the subdomain label. The relay is not a direct network destination — it's reached through the DNS system.
### The Key Advantage Over Passive Sniffing
The passive sniffing model (Model A in [`passive_sniffing_relay.md`](passive_sniffing_relay.md)) requires the relay to be physically on the network path between sender and destination. This is hard to achieve.
The authoritative DNS model requires **no special network positioning**. The DNS system delivers the query to your server automatically. You just need:
1. A domain name
2. A server with a public IP
3. DNS software configured to log or capture queries
---
## How DNS Resolution Works (The Full Chain)
When a sender queries `<base64_event>.yourdomain.com`, here is exactly what happens:
```
Step 1: Sender's application
→ Asks the OS resolver: "What is the IP of xyz.yourdomain.com?"
Step 2: OS resolver (stub resolver)
→ Checks local cache. If not found:
→ Forwards to configured DNS resolver (e.g., 8.8.8.8, 1.1.1.1, ISP's DNS)
Step 3: Sender's DNS resolver (recursive resolver)
→ Checks its own cache. If not found:
→ Asks a root nameserver: "Who manages .com?"
→ Root responds: "Ask a TLD server at a.gtld-servers.net"
→ Asks the .com TLD server: "Who manages yourdomain.com?"
→ TLD responds: "yourdomain.com is managed by ns1.yourdomain.com at 1.2.3.4"
→ Asks your server (1.2.3.4): "What is the IP of xyz.yourdomain.com?"
Step 4: Your authoritative nameserver (1.2.3.4) ← YOU ARE HERE
→ Receives the query
→ Logs the subdomain label (xyz...)
→ Responds with NXDOMAIN (no such record) or a fake IP
Step 5: Sender's DNS resolver
→ Receives the NXDOMAIN response
→ Returns it to the sender's application
→ Application sees: domain doesn't exist (normal)
```
The critical point: **Step 3 is automatic.** The sender's resolver does all the work of finding your server. You don't need to be on any special network path.
---
## How to Embed the Event
### DNS Label Encoding
A DNS query for a subdomain like:
```
<base64_event>.yourdomain.com
```
The sender encodes the Nostr event as a base64url string and uses it as a DNS label. Your server extracts the label from the query and decodes it.
#### DNS Label Constraints
| Constraint | Value | Impact |
|---|---|---|
| Max label length | 63 bytes | Event must fit in 63 bytes per label segment |
| Max total query length | ~255 bytes | Total encoded event + domain overhead |
| Character set | alphanumeric + hyphen | Base64url encoding required (no `+`, `/`, or `=`) |
| Case sensitivity | Case-insensitive | Use lowercase base64url |
#### Binary Event Fit
Using the `.bne` binary event format from [`max_single_packet_event.md`](../rethinking_nostr/max_single_packet_event.md):
| Format | Event Size | Base64url Size | Fits in Single Label? | Fits in Total Query? |
|---|---|---|---|---|
| JSON (kind 1) | ~400 bytes | ~533 bytes | No (exceeds 63) | No (exceeds 255) |
| Binary `.bne` (kind 1) | ~200 bytes | ~267 bytes | No (exceeds 63) | Borderline |
| Minimal `.bne` (no tags, short content) | ~120 bytes | ~160 bytes | No (exceeds 63) | **Yes** |
| Minimal `.bne` split across 3 labels | ~120 bytes | ~53 bytes/label | **Yes** | **Yes** |
#### Splitting Across Multiple Labels
If the event is too large for a single label, split it across multiple labels:
```
<part1>.<part2>.<part3>.yourdomain.com
```
Each label can hold up to 63 bytes. Three labels give ~189 bytes of base64url data, which decodes to ~141 bytes raw — enough for most single-packet events.
The sender splits the base64url string into chunks and joins them with dots. Your server extracts all labels before `yourdomain.com` and concatenates them.
#### Alternative: EDNS0 Option
EDNS0 (Extended DNS, RFC 6891) allows custom options in DNS packets. A Nostr event could be placed in a custom EDNS0 option:
```
DNS Query Header (12 bytes)
Question Section: <innocent_label>.yourdomain.com
EDNS0 OPT Pseudo-RR:
Option Code: 0xNSTR (custom, unassigned)
Option Data: <binary Nostr event>
```
This is more隐蔽 because the event is not visible in the subdomain label — it's in the EDNS0 option field. However, some DNS resolvers strip unknown EDNS0 options, so reliability may be lower.
---
## Setting Up the Authoritative DNS Server
### Option 1: Full DNS Server (nsd)
[`nsd`](https://www.nlnetlabs.nl/projects/nsd/about/) is a lightweight, authoritative-only DNS server. It does not do recursive resolution — it only answers queries for domains it is authoritative for.
**Installation:**
```bash
sudo apt-get install nsd
```
**Configuration (`/etc/nsd/nsd.conf`):**
```
server:
ip-address: 1.2.3.4
port: 53
zone:
name: yourdomain.com
zonefile: /etc/nsd/yourdomain.com.zone
```
**Zone file (`/etc/nsd/yourdomain.com.zone`):**
```
$ORIGIN yourdomain.com.
$TTL 3600
@ IN SOA ns1.yourdomain.com. admin.yourdomain.com. (
2024010101 ; serial
3600 ; refresh
900 ; retry
86400 ; expire
3600 ; minimum
)
@ IN NS ns1.yourdomain.com.
ns1 IN A 1.2.3.4
```
**Logging queries:** `nsd` can log all queries to syslog. Configure your syslog to capture DNS queries and pipe them to a script:
```bash
# In rsyslog config:
:programname, isequal, "nsd" /var/log/nsd-queries.log
```
Then a separate process tails this log file, extracts base64 labels, decodes them, and injects events into the relay.
### Option 2: Minimal Custom UDP Listener
You don't need a full DNS server. You can write a minimal UDP listener on port 53 that:
1. Listens for UDP datagrams on port 53
2. Parses the DNS query header to extract the question (subdomain)
3. Extracts the base64 label
4. Responds with a valid DNS response (NXDOMAIN)
5. Decodes the event and injects it into the relay
**Python example (conceptual):**
```python
import socket
import struct
import base64
def parse_dns_query(data):
"""Extract the queried domain name from a DNS query."""
# Skip DNS header (12 bytes)
pos = 12
labels = []
while True:
length = data[pos]
if length == 0:
break
pos += 1
labels.append(data[pos:pos+length].decode('ascii', errors='ignore'))
pos += length
return '.'.join(labels)
def build_nxdomain_response(data):
"""Build a DNS NXDOMAIN response for the given query."""
# Parse header
header = struct.unpack('!HHHHHH', data[:12])
query_id = header[0]
flags = 0x8183 # Response + NXDOMAIN
# Build response header + echo the question
response = struct.pack('!HHHHHH', query_id, flags, 1, 0, 0, 0)
response += data[12:12+len(data)-12] # Echo the question
return response
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind(('0.0.0.0', 53))
while True:
data, addr = sock.recvfrom(512)
domain = parse_dns_query(data)
# Extract the first label (before yourdomain.com)
if domain.endswith('.yourdomain.com'):
label = domain.split('.')[0]
try:
# Decode base64url (add padding if needed)
padding = 4 - len(label) % 4
if padding != 4:
label += '=' * padding
event_bytes = base64.urlsafe_b64decode(label)
# Inject into relay...
except:
pass # Invalid encoding, silently drop
# Always respond with NXDOMAIN
response = build_nxdomain_response(data)
sock.sendto(response, addr)
```
**Important:** Running a UDP listener on port 53 requires root privileges. Use `setcap` to grant the binary the `CAP_NET_BIND_SERVICE` capability, or run as root and drop privileges after binding.
### Option 3: DNS Log Parser (Passive)
If you already have a DNS server running (e.g., for your website), you can simply enable query logging and parse the logs:
```bash
# Tail the DNS query log
tail -F /var/log/nsd-queries.log | while read line; do
# Extract domain from log line
domain=$(echo "$line" | grep -oP 'query: \K\S+')
if [[ "$domain" == *".yourdomain.com" ]]; then
label=$(echo "$domain" | cut -d. -f1)
# Decode and inject...
fi
done
```
---
## Domain Registration and Configuration
### Step 1: Register a Domain
Choose a domain that looks innocent. Examples:
| Domain | Looks Like | Notes |
|---|---|---|
| `cdn-pull.example` | CDN edge server | Generic infrastructure |
| `api-cache.example` | API caching layer | Generic infrastructure |
| `metrics.example` | Analytics endpoint | Generic infrastructure |
| `status.example` | Status page | Generic infrastructure |
Avoid anything that suggests Nostr, crypto, or censorship circumvention.
### Step 2: Configure Nameservers
At your domain registrar, set the nameservers to point to your server:
```
ns1.yourdomain.com → 1.2.3.4
ns2.yourdomain.com → 1.2.3.4 (or a second server for redundancy)
```
### Step 3: Set Up Glue Records
Most registrars require **glue records** — A records for the nameservers themselves. This is because the DNS system needs to know the IP of `ns1.yourdomain.com` before it can query `yourdomain.com`. The registrar handles this automatically when you specify the nameserver IPs.
### Step 4: Wait for Propagation
DNS changes can take 24-48 hours to propagate fully, though most resolvers update within a few hours.
---
## Security and Operational Considerations
### 1. Rate Limiting
DNS servers are exposed to the public internet. An attacker could flood your server with fake queries. Implement rate limiting:
```bash
# iptables rate limit for DNS
iptables -A INPUT -p udp --dport 53 -m limit --limit 100/s -j ACCEPT
iptables -A INPUT -p udp --dport 53 -j DROP
```
### 2. Amplification Attack Risk
DNS servers can be used for amplification attacks if they respond with large responses. Always respond with a minimal NXDOMAIN response (no additional data). Never include DNSSEC records, NS records, or other data in the response.
### 3. Log Rotation
DNS query logs can grow quickly. Implement log rotation:
```bash
# /etc/logrotate.d/nsd-queries
/var/log/nsd-queries.log {
daily
rotate 7
compress
delaycompress
missingok
notifempty
postrotate
systemctl restart rsyslog
endscript
}
```
### 4. DNSSEC
If you enable DNSSEC, your responses will be signed and verifiable. This adds legitimacy but also complexity. For a stealth relay, DNSSEC is optional — NXDOMAIN responses without DNSSEC are normal for domains that don't have DNSSEC enabled.
### 5. Firewall
Only expose port 53 (UDP). Do not expose SSH, HTTP, or any other service on the same IP if you want to maintain the appearance of a simple DNS server.
---
## Comparison with Other Approaches
| Property | Direct UDP | Bridge Pattern | Passive Sniffing | **Authoritative DNS** |
|---|---|---|---|---|
| Sender connects to relay? | Yes | Yes (via bridge) | No | **No** |
| Relay visible in traffic? | Yes | Yes (bridge IP) | No | **Yes (domain name)** |
| Content hidden? | Yes (encrypted) | Yes (encrypted) | Yes (in DNS label) | **Yes (in DNS label)** |
| Plausible deniability | None | None | High | **Moderate** |
| Sender sophistication | Low | Low (browser) | Medium | **Medium** |
| Relay sophistication | Low | Low | High (packet capture) | **Low (DNS server)** |
| Works in browser? | No | Yes | No | **No** |
| Event size limit | 1472 bytes | 1472 bytes | ~200 bytes | **~200 bytes** |
| Real-time delivery | Yes | Yes | Delayed | **Near real-time** |
| Legal risk for relay | Low | Low | Medium | **Low** |
| Needs network path access? | No | No | **Yes** | **No** |
| Needs domain name? | No | No | No | **Yes** |
---
## The Cypherpunk Angle
### 1. DNS as a Universal Transport
DNS is the one protocol that virtually no firewall blocks entirely. Blocking DNS would break the internet. This makes it an ideal censorship-resistant transport.
### 2. The Domain as a Dead Drop
The domain name functions as a **dead drop location**. Anyone who knows the domain can send events to it. The sender doesn't need to know the server's IP — the DNS system handles that.
### 3. Traffic Analysis Limitations
An observer sees: `Sender queried yourdomain.com`. This is indistinguishable from a normal DNS lookup for a website. The observer would need to:
- Inspect the full subdomain label (which may be encrypted with EDNS0)
- Know the encoding scheme
- Distinguish Nostr events from random noise
Without all three, the traffic looks normal.
### 4. Relationship to Other Approaches
| Approach | Relationship |
|---|---|
| Protocol hardening (approach 6) | DNS is a hardened protocol — it cannot be blocked |
| Steganography (approach 4) | The event is hidden inside a DNS query |
| Anonymity (approach 1) | Can be combined with Tor for sender anonymity |
| Decentralization (approach 2) | Multiple domains can point to multiple relays |
---
## Limitations
### 1. Event Size
DNS queries are limited to ~255 bytes total. This restricts the approach to small, single-packet events using the `.bne` binary format. Larger events must use a different transport.
### 2. One-Way Only
The sender sends a query and receives a DNS response (NXDOMAIN). The response cannot carry meaningful data back to the sender (it's just a DNS status code). This is a publish-only channel.
### 3. Domain Visibility
The domain name is visible in the DNS query. If an adversary maintains a list of known Nostr relay domains, they can flag queries to those domains. Using innocent-looking domains mitigates this.
### 4. No Browser Support
Browsers do not expose APIs for crafting arbitrary DNS queries. The sender needs a custom application or a browser extension that can make raw DNS queries.
### 5. Caching
DNS resolvers cache responses. If the sender queries the same subdomain twice, the second query may be served from cache and never reach your server. The sender should include a random component in each query to avoid caching:
```
<base64_event>.<random_nonce>.yourdomain.com
```
The random nonce ensures each query is unique and bypasses the cache.
---
---
## Testing from a Browser
You can test the authoritative DNS relay from a standard browser page with no special APIs. The browser performs a DNS lookup for **any hostname** you give it, and that lookup reaches your authoritative nameserver.
### Method 1: Image Pixel (Simplest)
```javascript
// Encode the event as base64url
const eventBase64 = btoa(JSON.stringify(event))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
// Trigger a DNS lookup by loading an image from the subdomain
new Image().src = `https://${eventBase64}.yourdomain.com/pixel.png`
```
The browser:
1. Extracts the hostname: `${eventBase64}.yourdomain.com`
2. Asks its DNS resolver for the IP
3. DNS resolver queries your authoritative nameserver ← YOU RECEIVE THE EVENT
4. Your server responds with NXDOMAIN or an IP
5. Browser makes HTTPS request to the IP (fails or succeeds — doesn't matter)
### Method 2: Fetch
```javascript
fetch(`https://${eventBase64}.yourdomain.com/collect`)
```
Same DNS flow. The `fetch` itself may fail (CORS, no server), but the DNS query already delivered the event.
### Method 3: Multiple Queries in Parallel (for Large Events)
```javascript
// Split a large event across multiple DNS queries
const chunks = splitIntoChunks(eventBase64, 50) // 50 bytes per chunk
const sessionId = Math.random().toString(36).slice(2)
chunks.forEach((chunk, i) => {
const subdomain = `${sessionId}.${i}.${chunk}.yourdomain.com`
new Image().src = `https://${subdomain}/pixel.png`
})
```
Each chunk triggers a separate DNS lookup. Your server reassembles them by `sessionId`.
### What the Browser Sees
The user sees nothing unusual — the page loads normally. The image loads fail silently (broken image icon if using `<img>`), or you can suppress errors by using `fetch()` with `catch()`. The DNS queries happen in the background.
### Limitation: DNS Caching
DNS resolvers cache responses. If you send the same subdomain twice, the second query may be served from cache and never reach your server. Mitigations:
- Include a random nonce in each query: `<nonce>.<event>.yourdomain.com`
- Use a unique session ID per batch of queries
- The nonce ensures each query is unique and bypasses the cache
---
## Combining Multiple Queries for Larger Events
The ~255 byte DNS query limit can be overcome by splitting a large event across multiple DNS queries and reassembling on the server.
### Protocol
Each query carries three pieces of information in the subdomain:
```
<session_id>.<sequence_number>.<chunk_data>.yourdomain.com
```
| Field | Description | Example |
|---|---|---|
| `session_id` | Random identifier for this batch of chunks | `a3f8k2` |
| `sequence_number` | Position of this chunk (0-indexed) | `0`, `1`, `2` |
| `chunk_data` | Base64url-encoded chunk of the event | `eyJjb250ZW50Ijoi...` |
### Sender Logic (JavaScript)
```javascript
async function sendLargeEvent(event, domain) {
const json = JSON.stringify(event)
const base64 = btoa(json).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
// Split into 50-byte chunks (leaves room for session_id, seq, and domain overhead)
const chunkSize = 50
const chunks = []
for (let i = 0; i < base64.length; i += chunkSize) {
chunks.push(base64.slice(i, i + chunkSize))
}
const sessionId = Math.random().toString(36).slice(2, 8)
// Fire all chunks as parallel DNS queries
const promises = chunks.map((chunk, i) => {
const subdomain = `${sessionId}.${i}.${chunk}.${domain}`
// Use Image() for fire-and-forget (no response needed)
return new Promise((resolve) => {
const img = new Image()
img.onload = img.onerror = resolve
img.src = `https://${subdomain}/pixel.png`
})
})
await Promise.all(promises)
return { sessionId, totalChunks: chunks.length }
}
```
### Receiver Logic (Server)
```python
# In-memory buffer for partial events
chunk_buffer = {} # session_id -> { total_chunks: N, chunks: {seq: data} }
def process_dns_query(domain):
# Parse: <session_id>.<seq>.<chunk>.yourdomain.com
parts = domain.split('.')
if len(parts) < 4:
return # Not our format
session_id = parts[0]
try:
seq = int(parts[1])
except ValueError:
return
chunk_data = parts[2]
# Initialize or update buffer
if session_id not in chunk_buffer:
chunk_buffer[session_id] = {}
chunk_buffer[session_id][seq] = chunk_data
# Check if we have all chunks (we don't know total yet — use timeout)
# For simplicity, assume complete after 5 seconds of no new chunks
```
### Determining Completion
The server doesn't know the total number of chunks in advance. Strategies:
| Strategy | How It Works | Tradeoff |
|---|---|---|
| **Timeout** | After receiving a chunk, wait N seconds. If no new chunks arrive, assume complete. | Simple. Adds latency. |
| **Total in first chunk** | First chunk includes total count: `<session>.0.<total_N>.<data>` | Requires special first-chunk format. |
| **Final chunk marker** | Last chunk has a special marker: `<session>.99.<data>.final` | Sender must know which chunk is last. |
| **Event ID as session** | Use the Nostr event ID as the session ID. Server knows the expected event size from the kind. | Only works for known event kinds. |
### Effective Size Limits
| Number of Queries | Total Raw Data | Total Base64 Data | Use Case |
|---|---|---|---|
| 1 | ~140 bytes | ~190 bytes | Minimal text note |
| 3 | ~420 bytes | ~570 bytes | Normal text note with tags |
| 5 | ~700 bytes | ~950 bytes | Long text note |
| 10 | ~1400 bytes | ~1900 bytes | Large event with metadata |
| 20 | ~2800 bytes | ~3800 bytes | Very large event |
With 10 DNS queries, you can send any Nostr event that fits in a single UDP packet (1472 bytes). With 20 queries, you can send events larger than a single UDP packet.
### What the Observer Sees
An observer sees 10 DNS queries to your domain in quick succession:
```
a3f8k2.0.eyJjb250ZW50IjoiSGVsbG8gV29ybGQhIn0.yourdomain.com
a3f8k2.1.Li4udGhpcyBpcyBhIGxvbmdlciB0ZXh0IG5vdGUgdGhhdCB3b3VsZ...
a3f8k2.2.G5vdCBmaXQgaW4gYSBzaW5nbGUgRFBTIHF1ZXJ5LCBzbyB3ZSBzcG...
...
```
This looks like a client resolving multiple subdomains — which is normal behavior for a web page loading resources from multiple CDN endpoints. The pattern is indistinguishable from:
- A web page loading 10 images from a CDN
- An analytics script tracking page load metrics
- A JavaScript widget making multiple API calls
### Relationship to the Bridge Pattern
The multi-query DNS approach can replace the bridge pattern entirely for browser-based sending:
```
Browser → Multiple DNS queries → Your authoritative DNS server → Event reassembly → Relay
```
No HTTP bridge needed. No UDP socket needed. The browser's built-in DNS resolver does all the work. The relay receives the fully reassembled event and serves it to subscribers via WebSocket.
This is the **most censorship-resistant browser-based approach** because:
- DNS cannot be blocked without breaking the internet
- Each individual query looks like normal web traffic
- The event is fragmented across multiple queries, making reassembly harder for an observer
- No direct connection to a known relay IP
---
## Summary
The authoritative DNS relay is a practical approach to censorship-resistant Nostr event transmission that:
1. **Requires no special network positioning** — the DNS system delivers queries to your server automatically
2. **Provides content hiding** — the event is encoded in the subdomain label
3. **Leverages existing infrastructure** — DNS is universally allowed and cannot be blocked
4. **Is testable from a browser**`new Image()` triggers a DNS lookup with no special APIs
5. **Supports large events via multi-query splitting** — 10 queries can deliver any single-packet event
6. **Works with the binary `.bne` format** — small events fit within DNS label constraints
7. **Eliminates the need for a bridge** — the browser's DNS resolver replaces the HTTP bridge entirely
The core insight: **the global DNS system is a free delivery network. Anyone can send data to your server by querying a subdomain, and the query looks like normal internet traffic.**
+328
View File
@@ -0,0 +1,328 @@
# Encrypting Incoming UDP Nostr Events to the Relay
## 1. Motivation
Right now a UDP Nostr datagram carries a signed-but-**plaintext** Nostr event. The signature authenticates the sender and replaces the handshake, but it does not hide the content. A passive observer on the wire can read every field — `pubkey`, `kind`, `tags`, `content` — even though they cannot block it without blocking all UDP.
The relay (`c-relay`) already owns a static Nostr keypair. We can use it as a **decryption keypair**: the sender encrypts the event **to the relay's pubkey**, the relay decrypts with its private key, then verifies the inner signature and stores the event as usual.
The wire format we want is **only ciphertext** — no Nostr event JSON wrapper, no `.bne` envelope, no framing. The UDP datagram is a single opaque blob of encrypted bytes. To a DPI box it is indistinguishable from random noise; to the relay it is a sealed event only it can open.
This document compares the two candidate NIP encryption schemes — **NIP-04** and **NIP-44 v2** — purely on the axis that matters most for single-packet UDP: **ciphertext size**.
---
## 2. The Self-Contained Ciphertext Requirement
This is the deciding constraint, and it is easy to miss.
Both NIP-04 and NIP-44 derive their encryption key from **ECDH on secp256k1**:
```
shared_secret = x( DH(sender_privkey, recipient_pubkey) )
```
The relay (recipient) holds its private key. To compute the same shared secret it also needs **the sender's public key**. The question is: *where does the sender's pubkey come from?*
| Scheme | Sender pubkey source | Self-contained? |
|---|---|---|
| **NIP-04** | The `pubkey` field of the surrounding Nostr event envelope | **No** — relay cannot decrypt without the event wrapper |
| **NIP-44 v2** | An **ephemeral pubkey embedded in the ciphertext payload** | **Yes** — relay decrypts from ciphertext alone |
Because we want **only ciphertext on the wire, no event wrapper**, NIP-04 in its native form is broken: the relay receives opaque bytes, has no sender pubkey, and cannot derive the shared secret. NIP-44 v2 was designed precisely to solve this — its payload carries a fresh ephemeral pubkey for every message, so the relay needs nothing but its own private key.
> **Implication:** NIP-04 is only viable if we prepend the sender's (or an ephemeral) pubkey to the ciphertext ourselves — at which point we have reinvented a weaker, non-standard version of NIP-44. We analyze this variant anyway, labeled **NIP-04+eph** below, for a fair size comparison.
---
## 3. NIP-04 — Size Breakdown
NIP-04 (deprecated, but still widely implemented) uses:
- **KDF:** raw ECDH-X (the x-coordinate of the DH point) as the AES key. No HKDF, no salt.
- **Cipher:** AES-256-CBC with a random 16-byte IV.
- **Padding:** PKCS#7, 116 bytes (always present — a full block is added when the plaintext is a multiple of 16).
- **Native wire format (inside event content):** `<iv_hex>?<ciphertext_hex>` — hex-doubled, but we discard this; we care about raw bytes.
### Raw byte layout (no event wrapper)
```
+-----------------------------+----------------+
| Field | Size (bytes) |
+-----------------------------+----------------+
| IV | 16 |
| AES-256-CBC ciphertext | ceil((P+1)/16)*16 |
+-----------------------------+----------------+
| TOTAL | 16 + padded |
+-----------------------------+----------------+
```
Where `P` = plaintext length.
### NIP-04+eph variant (self-contained)
To make NIP-04 decryptable without an event wrapper, prepend a 32-byte ephemeral pubkey:
```
+-----------------------------+----------------+
| Field | Size (bytes) |
+-----------------------------+----------------+
| Ephemeral pubkey (32B) | 32 |
| IV | 16 |
| AES-256-CBC ciphertext | ceil((P+1)/16)*16 |
+-----------------------------+----------------+
| TOTAL | 48 + padded |
+-----------------------------+----------------+
```
**Overhead:** 48 bytes fixed + 116 bytes PKCS#7 padding = **4964 bytes total overhead.**
---
## 4. NIP-44 v2 — Size Breakdown
NIP-44 v2 (the current standard) uses:
- **KDF:** ECDH-X → HKDF-SHA256 (with per-message salt).
- **Cipher:** ChaCha20 (stream cipher — ciphertext is same length as padded plaintext, no block expansion).
- **MAC:** HMAC-SHA256, 32 bytes, over `version || ephemeral_pubkey || nonce || ciphertext`.
- **Padding:** a deterministic size-rounding scheme to frustrate traffic analysis.
- **Native wire format:** base64 of the payload — we discard the base64; we care about raw bytes.
### Raw byte layout (no event wrapper, no base64)
```
+-----------------------------+----------------+
| Field | Size (bytes) |
+-----------------------------+----------------+
| version (0x02) | 1 |
| Ephemeral pubkey | 32 |
| Nonce | 32 |
| Ciphertext (padded) | padded_len(P) |
| MAC | 32 |
+-----------------------------+----------------+
| TOTAL | 97 + padded_len(P) |
+-----------------------------+----------------+
```
### NIP-44 padding function
```python
def padded_len(unpadded):
if unpadded <= 32:
return 32
next_power = 1 << (floor(log2(unpadded - 1)) + 1)
if next_power <= 256:
return next_power # 32, 64, 128, 256
else:
return floor(unpadded / 256 + 1) * 256 # 512, 768, 1024, 1280, ...
```
Valid padded sizes: **32, 64, 128, 256, 512, 768, 1024, 1280, 1536, …**
This rounding can add substantial overhead for plaintexts that fall just above a boundary (e.g. a 257-byte plaintext pads to 512 — nearly 2× expansion).
**Overhead:** 97 bytes fixed + variable padding (0 to ~2× for small messages, averaging ~50% near boundaries).
---
## 5. Side-by-Side Ciphertext Size Comparison
`P` = plaintext length (the raw Nostr event bytes — JSON or `.bne` — being sealed).
| Plaintext `P` | NIP-04+eph total | NIP-44 v2 total | NIP-44 padding waste | Winner |
|---|---|---|---|---|
| 32 | 80 | 129 | 0 | NIP-04+eph |
| 64 | 112 | 161 | 0 | NIP-04+eph |
| 128 | 176 | 225 | 0 | NIP-04+eph |
| 200 | 256 | 297 | 56 (→256) | NIP-04+eph |
| 256 | 304 | 353 | 0 | NIP-04+eph |
| 300 | 368 | 609 | 212 (→512) | NIP-04+eph |
| 512 | 560 | 609 | 0 | NIP-04+eph |
| 800 | 848 | 897 | 0 | NIP-04+eph |
| 1024 | 1072 | 1121 | 0 | NIP-04+eph |
| 1100 | 1148 | 1197 | 0 | NIP-04+eph |
| 1279 | 1328 | 1376 | 1 (→1280) | NIP-04+eph |
| 1455 | 1504 | — (over MTU) | — | NIP-04+eph only |
**NIP-04+eph is smaller at every plaintext size.** The gap is smallest when `P` lands exactly on a NIP-44 padding boundary (32/64/128/256/512/1024/1280…), where NIP-44 wastes only its 97-byte fixed overhead vs NIP-04+eph's 48-byte fixed overhead — a constant **49-byte difference**. The gap explodes near the *upper* edge of a NIP-44 padding tier (e.g. `P=300` → NIP-44 pads to 512, wasting 212 bytes).
---
## 6. Maximum Plaintext That Fits in a 1472-Byte UDP Datagram
This is the number that actually decides the design. UDP payload budget = 1500 (Ethernet MTU) 20 (IPv4) 8 (UDP) = **1472 bytes**. The entire ciphertext must fit in this.
| Scheme | Fixed overhead | Max plaintext `P` in 1472 B |
|---|---|---|
| **NIP-04+eph** | 48 B + PKCS#7 (116) | **1455 bytes** |
| **NIP-44 v2** | 97 B + power-of-2 padding | **1279 bytes** |
Derivation:
- **NIP-04+eph:** `48 + ceil((P+1)/16)*16 ≤ 1472` → ciphertext budget 1424 → `P ≤ 1423` with 1-byte pad, practically **1455** when `P mod 16 = 15` (pad = 1). Safe round figure: **~1440 bytes** of plaintext event.
- **NIP-44 v2:** `97 + padded_len(P) ≤ 1472``padded_len(P) ≤ 1375`. The largest valid NIP-44 padded size ≤ 1375 is **1280** (next is 1536, too big). So `P ≤ 1279`. Safe round figure: **~1279 bytes** of plaintext event.
**NIP-04+eph carries ~176 more bytes of actual event per datagram** — roughly 12% more payload per packet. For a protocol whose entire reason for existing is the 1472-byte single-packet boundary, that is a meaningful win.
---
## 7. Tradeoffs Beyond Size
Size is not the only axis. The schemes differ in ways that matter for a censorship-resistant relay:
| Property | NIP-04+eph (custom) | NIP-44 v2 (standard) |
|---|---|---|
| **Standardized / audited** | No — we invented the `eph||iv||ct` framing | Yes — NIP-44 v2 is the current NIP, audited, widely implemented |
| **KDF** | Raw ECDH-X (no HKDF, no salt) — **cryptographically weaker**, known to leak info in some models | HKDF-SHA256 with per-message salt — **standard** |
| **Cipher** | AES-256-CBC — malleable, no integrity | ChaCha20 + HMAC-SHA256 — **authenticated encryption** (encrypt-then-MAC) |
| **Integrity** | None — ciphertext can be tampered; relay must rely on inner Nostr sig for integrity | Built-in MAC — relay detects tampering **before** decrypting |
| **Traffic analysis resistance** | None — ciphertext size ≈ plaintext size + tiny pad | Built-in padding to power-of-2 buckets — sizes are quantized, harder to fingerprint |
| **Ecosystem compat** | None — custom | `nak` and most clients can already produce NIP-44 ciphertext |
| **Self-contained (no wrapper)** | Yes (with our +eph patch) | Yes, natively |
| **Max plaintext in 1472 B** | ~1455 B | ~1279 B |
The security story is clear: **NIP-44 v2 is the cryptographically correct choice.** NIP-04's raw-ECDH-without-HKDF and unauthenticated CBC are both considered broken by modern standards — NIP-04 is formally deprecated for these reasons. NIP-44's MAC also gives the relay a cheap tamper check *before* it spends a Schnorr verification on the inner event, which is a nice DoS-amplification defense.
NIP-44's padding — the very thing that costs us ~176 bytes — is also a **feature**: it quantizes message sizes into a small set of buckets, making traffic-analysis harder. For a censorship-resistance tool, that is on-mission.
---
## 8. Recommendation
**Use NIP-44 v2, raw bytes (no base64, no event wrapper), as the UDP datagram payload.**
Rationale:
1. It is the only standardized, self-contained option — the relay decrypts with just its private key.
2. Authenticated encryption (ChaCha20 + HMAC) gives tamper detection before signature verification.
3. HKDF + per-message salt is the cryptographically modern KDF.
4. Power-of-2 padding doubles as traffic-analysis resistance — on-mission for censorship resistance.
5. The ~176-byte capacity cost (1279 B vs 1455 B max plaintext) is real but acceptable: a 1279-byte plaintext still holds a full `.bne` event with ~1100 bytes of UTF-8 content, which is more than a long tweet and enough for most kind:1 notes.
**Reserve NIP-04+eph only for a future "max payload" mode** where a sender absolutely must push a ~1450-byte event in one datagram and is willing to accept the weaker crypto and custom framing. It should not be the default.
---
## 9. Proposed Wire Format (NIP-44 v2, raw)
```
UDP datagram (≤ 1472 bytes):
+---------------------------------------------------+
| version (1B) = 0x02 |
| ephem_pub (32B) = fresh secp256k1 ephemeral key |
| nonce (32B) = random |
| ciphertext (N B) = ChaCha20(padded_plaintext) |
| mac (32B) = HMAC-SHA256(key, header||ct) |
+---------------------------------------------------+
```
The relay's decryption flow:
```mermaid
flowchart TD
A[UDP datagram received] --> B[Parse version, eph_pub, nonce, ct, mac]
B --> C{version == 0x02?}
C -- No --> X[Drop - not NIP-44 v2]
C -- Yes --> D[ECDH: shared = DH relay_priv, eph_pub]
D --> E[HKDF-SHA256: derive enc_key and mac_key]
E --> F{HMAC verifies?}
F -- No --> Y[Drop - tampered or junk]
F -- Yes --> G[ChaCha20 decrypt + unpad]
G --> H[Plaintext Nostr event bytes]
H --> I[Parse + verify Schnorr signature]
I --> J[Store event, serve via WebSocket]
```
Note the two-stage gate: **MAC check before signature check.** A junk/probe datagram fails the MAC in constant time and is dropped without ever invoking the secp256k1 verifier — a cheap, signature-free way to reject active probes. This reinforces the no-handshake property from [`docs/no_handshake.md`](docs/no_handshake.md:43): an adversary's probe gets the same "nothing happened" response as a server that isn't running Nostr at all.
---
## 10. Constant-Size Padding to Full MTU
### 10.1 The idea
Pad every UDP datagram to **exactly 1472 bytes** — the maximum unfragmented UDP payload — regardless of how small the inner event is. A 50-byte note and a 1200-byte note both produce a 1472-byte datagram on the wire.
### 10.2 Why it helps
**Blending with the highest-volume traffic on the internet.** By *byte volume*, full-size 1472-byte UDP packets are the dominant UDP shape on the modern internet:
| Traffic type | Typical UDP payload size | Share of internet bytes |
|---|---|---|
| QUIC / HTTP/3 | 1472 B (full MTU) | ~3050% of all traffic |
| WireGuard | 1472 B (full MTU) | growing |
| DNS | 50512 B | tiny by bytes, huge by packet count |
| VoIP / gaming | 100300 B | small |
A single 1472-byte UDP datagram is what every QUIC connection emits constantly. It is the single least remarkable packet shape on the network. This is the same principle documented in [`docs/protocol_hardening.md`](docs/protocol_hardening.md:44): *make your message look exactly like the most common, most essential traffic.*
**Killing the size dimension of traffic analysis.** With variable-size packets, an adversary can fingerprint a relay by the *distribution* of datagram sizes leaving it (e.g. "this IP sends a lot of 200-byte and 400-byte UDP packets — looks like short notes"). With constant 1472-byte padding, the size distribution is a delta function at 1472. Size tells the adversary nothing. This is the same technique used by Tor's padding, obfs4's padding, and djb's constant-rate cover-traffic designs.
**The user's intuition is correct for the fire-and-forget case.** Most 1472-byte UDP packets on the internet are fragments of a larger QUIC/WireGuard stream. Our single 1472-byte packet is indistinguishable from one QUIC data packet among billions. For a single event (the core UDP Nostr use case), full-size padding is strictly better cover than sending a tiny 80-byte packet that screams "this is not a normal bulk transfer."
### 10.3 The honest caveat: streams, not singletons
Constant-size padding neutralizes *size* analysis but not *timing* or *volume* analysis. If a sender transmits a stream of events to one relay IP on one port, the adversary sees a steady drip of 1472-byte UDP packets — a recognizable *pattern* even if each packet is unremarkable. This is a timing problem, not a size problem, and padding to 1472 does not make it worse (it removes one signal, size, leaving only timing).
For the intended use case — low-volume, fire-and-forget censorship-resistant notes — this is acceptable. For high-volume streaming, one would want to add dummy cover traffic (constant-rate padding, as in Tor's `PAD_*` cells) to flatten timing too. That is a future extension, not a blocker for the current design.
### 10.4 The framing subtlety (and how to solve it)
NIP-44 v2 infers its ciphertext length from the **total payload length**: `ct_len = total 97` (where 97 = version + eph + nonce + mac). If we simply append random padding to the datagram, the relay cannot tell where the NIP-44 payload ends and the outer padding begins — it would try to decrypt a too-long ciphertext and fail.
Two clean solutions:
**Option A — 2-byte length prefix (recommended):**
```
+----------------------------------------------+
| nip44_payload_len (2B, big-endian) |
| nip44_payload (L bytes, 129 ≤ L ≤ 1377) |
| random_pad (1472 - 2 - L bytes) |
+----------------------------------------------+
| TOTAL = exactly 1472 bytes |
+----------------------------------------------+
```
Relay: read 2 bytes → `L` → read `L` bytes → NIP-44 decrypt → ignore the rest. The 2-byte prefix is **free**: without it, max NIP-44 payload is 1472 B (capped at the 1280-byte inner tier → 1279 B plaintext); with it, max payload is 1470 B, still capped at the same 1280-byte tier. Plaintext capacity is unchanged at **1279 bytes**.
**Option B — fixed NIP-44 inner size:**
Always pad the NIP-44 plaintext to its maximum tier (1280 B), producing a fixed 1377-byte NIP-44 payload, then outer-pad to 1472 with 95 random bytes. No length prefix needed — the relay always reads exactly 1377 bytes. Simpler, but it couples the inner and outer padding and wastes the 95 bytes we could otherwise use for content. Since Option A costs nothing in capacity, Option A is preferred.
### 10.5 Updated wire format (NIP-44 v2 + constant-size outer pad)
```
UDP datagram (EXACTLY 1472 bytes, every time):
+---------------------------------------------------+
| nip44_payload_len (2B) = L, big-endian |
| version (1B) = 0x02 |
| ephem_pub (32B) = fresh ephemeral key |
| nonce (32B) = random |
| ciphertext (L-97 B) = ChaCha20(padded_pt) |
| mac (32B) = HMAC-SHA256 |
| random_pad (1472 - 2 - L B) = random |
+---------------------------------------------------+
```
Every datagram is exactly 1472 bytes of high-entropy data (the NIP-44 payload is indistinguishable from random, and the trailing pad is random). To any observer — passive or active — it looks like a single QUIC/WireGuard data packet.
### 10.6 Cost
**Bandwidth:** a 50-byte note becomes 1472 bytes on the wire — a ~29× expansion. For fire-and-forget censorship-resistant notes (the design target), this is irrelevant: the cost is one UDP packet, sent rarely. For high-volume use it would be wasteful, but UDP Nostr is not designed for high volume.
**Capacity:** unchanged. Max inner plaintext is still 1279 bytes (the NIP-44 1280-byte tier). The 2-byte length prefix and the outer random padding consume only bytes that were already unused.
### 10.7 Recommendation
**Yes — pad every datagram to exactly 1472 bytes, using Option A (2-byte length prefix + random trailing pad).** It is free in capacity, trivial to implement, and converts the wire signal from "variable-size encrypted blobs" (a fingerprintable distribution) into "constant 1472-byte high-entropy UDP" (the most common packet shape on the modern internet). This is on-mission for the protocol-hardening thesis and closes the size-analysis side channel that variable-length NIP-44 would otherwise leave open.
---
## 11. Open Questions
- **Does `nak` emit raw NIP-44 v2 bytes (pre-base64)?** If it only emits base64, the sender tooling needs a small wrapper to strip the base64 before `nc -u`. Trivial, but worth confirming.
- **Should the relay advertise its pubkey via DNS / a well-known endpoint**, or is it out-of-band (e.g. published in its WebSocket `NIP-11` document)? Senders need it to encrypt.
- **Kind for the inner event.** Since the wire is pure ciphertext, the `kind` is hidden from DPI — a nice bonus. We may want a convention (e.g. always `kind:1`, or a new `kind:444` for "sealed UDP note") for relay-side filtering.
- **Replay protection.** NIP-44's nonce is random, not a counter, so the relay cannot enforce monotonicity. For a fire-and-forget UDP relay this is probably fine (the inner Nostr `created_at` + event id dedup handles replays), but worth noting.
@@ -75,7 +75,7 @@ The sender crafts a DNS query where one of the labels contains the base64-encode
#### Binary Event Fit
Using the `.bne` binary event format from [`max_single_packet_event.md`](../rethinking_nostr/max_single_packet_event.md):
Using the `.bne` binary event format from [`max_single_packet_event.md`](max_single_packet_event.md):
| Format | Event Size | Base64 Size | Fits in DNS? |
|---|---|---|---|
+74
View File
@@ -0,0 +1,74 @@
# NOSTR over single packet UDP
## TLDR
Send a Nostr event as one-way UDP packet instead of using TCP. This one-way send is very censorship resistant compared to the alternatives. I currently have it implemented on my relay and you can test it now on the command line using nc and nostr:npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6's nak.
'''
nak event -k 1 -c "I don't shake hands, because Ewwwwww!" | nc -u -w1 laantungir.net 443
'''
## The problem:
https://blossom.laantungir.net/b63479f895a207fccfbac9323ac7a4bd47cbc20e43ebfcd60f3c54e41bfb1d1d.jpg
As far as I know, every circumvention tool [VPN, Tor, Shadowsocks, obfs4] has a handshake. You start off messaging the server, and the server replies before you can send your information. National firewalls detect those handshakes, fingerprint them, probe the handshake, then block the IP or take other action. The handshake is a vulnerability. Every tool plays cat-and-mouse obfuscating it.
For example Tor bridges are vulnerable to active probing. The adversary connects to a suspected bridge and sends data. If the server responds with a valid obfs4 handshake, the adversary knows it is a bridge. This is how China blocks many Tor bridges.
## The idea:
UDP Nostr eliminates the handshake entirely. The Nostr event is already signed before it touches the network. The signature replaces the handshake. There is no SYN, no TLS ClientHello, no SNI, no key exchange, no recognizable structure — just a blob of bytes in one UDP packet under 1472 bytes.
The adversary can send a probe, but the relay doesn't respond at all. No handshake to detect. No TCP RST to inject (UDP has no RST). No DNS to poison (send to the IP directly).
## Live example — full zero-config round-trip
My relay runs my c-nostr-pg project and has fully implemented UDP Nostr.
https://blossom.laantungir.net/5b1c5fc4ddf89b4aec123c7a2a5934e9a22c3271fa641b071cc8f7740a342bbd.png
**command line**
```bash
# Console 1 - Set up a live watch for the nak default events on the relay
nak req -a 79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 --stream laantungir.net/relay
# Console 2 - Send via UDP (no --sec needed, nak uses the default key)
nak event -k 1 -c "Hello!" | nc -u -w1 laantungir.net 443
```
**Python:**
```python
import socket, sys
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.sendto(sys.stdin.buffer.read(), (sys.argv[1], int(sys.argv[2])))
```
```bash
nak event -k 1 -c "Hello!" | python3 send.py laantungir.net 443
```
**JavaScript (Node.js):**
```javascript
const dgram = require("dgram"), s = dgram.createSocket("udp4");
process.stdin.on("data", b => s.send(b, +process.argv[3], process.argv[2]));
```
```bash
nak event -k 1 -c "Hello!" | node send.js laantungir.net 443
```
## An example of largest kind:1 that fits in one packet
With the default nak key, empty tags, and a 10-digit timestamp, the Nostr JSON overhead (structural syntax + hex-expanded id/pubkey/sig) is ~340 bytes, leaving **~1132 characters of content** in the 1472-byte UDP budget. Below is an illustrative event at full capacity — its single-line JSON form is exactly 1472 bytes on the wire, one Ethernet frame, unfragmented. (The `id` and `sig` shown are placeholders for readability; a real event carries a SHA-256 `id` and valid Schnorr `sig`.)
```json
{"id":"426462725f7061636b65745f626f756e646172795f746573745f303030303030",
"pubkey":"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
"created_at":1772019044,
"kind":1,
"tags":[],
"content":"The cypherpunk problem: how do two computers communicate freely when the network between them is controlled by an adversary? The standard approaches are anonymity (hide which computer is talking) and decentralization (make copies everywhere so you can't stop them all). But there is a third approach that is less explored: protocol substrate hardening. Design the communication protocol so that interference requires violating physics or OS-level guarantees. The simplest example: fit your message in a single UDP datagram under 1472 bytes. At this size, your message is exactly one Ethernet frame. The adversary cannot block it without blocking all UDP traffic on that port. They cannot fragment it. They cannot reassemble it. They cannot probe for a handshake because there is no handshake. The Nostr event is self-validating: it carries its own signature. The signature replaces the handshake. This is the key insight. Every other protocol announces itself with a handshake. TCP has SYN. TLS has ClientHello. QUIC has its Initial packet. WireGuard has its handshake initiation. All of these create a pattern that can be fingerprinted and blocked. A Nostr event over UDP has no pattern. It is just bytes on the wire. The receiver validates the signature using only the pubkey in the event. No shared secrets. No session setup. No state. This is the no-handshake property. It is the fundamental advantage of Nostr over every other protocol for censorship resistance. Combine this with the MTU boundary and you have a protocol that cannot be blocked without blocking all UDP traffic. The adversary faces a dilemma: block UDP entirely and break the internet, or let your messages through. This is the asymmetry we exploit.",
"sig":"4b57c22b1797b109530ffe5d04cabac468b1a5942873a5141334ecbc77694fc968a1b941979ba13602fceb1dad8014ab6469c6ae9cef0b5668cc23ad1449e103"}
```
## Future work
I'm currently working on an encryption scheme specifically for this protocol.
+293
View File
@@ -0,0 +1,293 @@
# Plan: UDP Nostr Relay on Cloud-Provider Load-Balancer IPs
## Goal
Make a UDP Nostr relay's IP **painful to block** by placing it behind a cloud-provider load balancer whose IP sits in a range shared across many customers, and **useless to block** by giving the relay mobility via FIPS discovery. The no-handshake property of UDP Nostr ([`docs/no_handshake.md`](../docs/no_handshake.md)) is preserved throughout.
This plan focuses specifically on **cloud-provider shared load-balancer IPs** — AWS, GCP, and Azure load balancers and serverless front-ends that sit on IPs from the provider's shared pools.
## The principle, and the honest collateral model
The Cloudflare/Great Firewall observation in this project ([`README.md`](../README.md:15), [`docs/mtu_exploration.md`](../docs/mtu_exploration.md:21), [`docs/protocol_hardening.md`](../docs/protocol_hardening.md:46)) is one principle:
> Make your message look exactly like the most common, most essential traffic on the network. When you do this, the adversary cannot block you without blocking everything.
Cloudflare is the famous instance: its anycast edge IPs front hundreds of thousands of unrelated customer domains from the *same IP address*. Blocking one Cloudflare IP is politically untenable because it breaks all those customers at once. That is **per-IP collateral** — one IP, many independent users.
Cloud-provider load balancers work differently, and it is important to be honest about the distinction:
| Collateral type | How it works | Example | Strength |
|---|---|---|---|
| **Per-IP collateral** | One IP serves many unrelated customers simultaneously | Cloudflare anycast, GCP HTTP(S) LB anycast | Very high — blocking one IP breaks many independent users |
| **Range-level collateral** | Your IP is dedicated to you, but lives in a provider IP range shared by millions of services | AWS NLB, AWS Global Accelerator, Azure LB | High at the range level — blocking the range breaks millions of services, but blocking your single IP breaks only you |
**The honest assessment:** most cloud load balancers that support UDP give you **dedicated IPs** (per-customer), not shared IPs. The collateral is at the **range level**: your IP lives in AWS's / GCP's / Azure's IP space, and blocking that range breaks an enormous number of legitimate services. A regime that wants to block your specific relay IP can do so with low collateral (it's your IP alone), but a regime that blocks by CIDR range (the common approach for blocking cloud-hosted proxies/VPNs) faces extreme collateral damage.
This means the cloud-LB approach provides:
1. **Protection against range-based blocking.** Regimes that block cloud IP ranges (a common tactic) cannot block your relay without blocking the entire cloud provider — which breaks millions of services. This is the primary defense.
2. **No protection against targeted IP blocking.** If the regime identifies your specific NLB IP and blocks just that IP, only your relay goes down. This is where **mobility** (the second layer of this plan) becomes essential — the relay moves to a new IP in the same or a different cloud provider's range.
### Why this is still worth doing
Despite the per-IP limitation, the cloud-LB approach has real advantages over a bare VPS:
- **Cloud IP ranges are extremely high collateral.** AWS, GCP, and Azure collectively host a huge fraction of the internet. Many regimes already struggle with this — they want to block cloud-hosted circumvention tools but blocking AWS ranges breaks too many legitimate services. This is documented in the censorship resistance literature: the GFW partially blocks some cloud ranges but cannot fully block them without unacceptable damage.
- **Anycast IPs (AWS Global Accelerator, GCP Network LB) are harder to target.** They're advertised from multiple edge locations via BGP. A regime must block the IP at all edge locations or accept partial reachability.
- **IP mobility is built-in.** Cloud LBs let you move IPs, change backends, and redeploy in different regions without changing the client-facing endpoint. This composes naturally with the FIPS discovery plan.
- **The IP looks like legitimate cloud traffic.** UDP to an AWS NLB IP is indistinguishable from UDP to any other AWS-hosted UDP service (gaming servers, VoIP, IoT). Combined with the no-handshake property, there is nothing to fingerprint.
## Which cloud load balancers support UDP
| Provider | Load balancer | UDP support | IP model | Anycast? |
|---|---|---|---|---|
| **AWS** | NLB (Network Load Balancer) | ✅ Yes | Static IPs per AZ (dedicated to you) | No (per-AZ unicast) |
| **AWS** | Global Accelerator | ✅ Yes | 2 anycast IPs from AWS pool (dedicated to you) | Yes (BGP anycast from AWS edge) |
| **AWS** | ALB (Application LB) | ❌ No (HTTP/HTTPS only) | — | — |
| **AWS** | CloudFront | ❌ No (HTTP/HTTPS only) | — | — |
| **GCP** | Network Load Balator (pass-through) | ✅ Yes | Anycast IP (regional) or unicast | Yes (regional anycast) |
| **GCP** | Global External HTTP(S) LB | ❌ No (TCP only) | Anycast (shared pool) | Yes |
| **Azure** | Load Balancer (Standard) | ✅ Yes | Frontend IP (dedicated to you) | No |
| **Azure** | Front Door | ❌ No (HTTP/HTTPS only) | — | — |
**The UDP-capable options are: AWS NLB, AWS Global Accelerator, GCP Network LB, and Azure Load Balancer.** All provide dedicated IPs (not per-IP shared like Cloudflare). The collateral is range-level.
### Recommended: AWS Global Accelerator
AWS Global Accelerator is the strongest option for this use case because:
1. **Anycast IPs.** You get two anycast IPs that are advertised from AWS's global edge network via BGP. Traffic enters at the nearest edge and traverses AWS's private backbone to your backend. A regime must block the IP at all edge locations or accept partial reachability.
2. **UDP support.** Global Accelerator supports UDP listeners.
3. **IP is from AWS's anycast pool.** The IP is in AWS's IP space, which is extremely high collateral at the range level.
4. **Backend mobility.** You can change the backend endpoint (your relay host) without changing the client-facing anycast IPs. The relay can move behind the same accelerator.
5. **Health checks.** Global Accelerator routes traffic only to healthy backends. If your relay goes down, traffic can route to a standby.
**Cost consideration:** Global Accelerator has a fixed hourly charge (~$0.025/hr for the accelerator) plus data transfer premiums. This is more expensive than a bare NLB but the anycast routing and backend mobility are worth it for this use case.
### Alternative: AWS NLB (cheaper, no anycast)
If cost is a concern, an AWS NLB provides UDP support with static IPs per AZ. The IPs are dedicated to you but in AWS's IP range. You lose anycast routing (the IP is unicast in one region), but you keep range-level collateral and UDP support. The NLB is cheaper (~$0.022/hr plus LCU charges).
### Alternative: GCP Network Load Balancer
GCP's pass-through Network LB supports UDP and provides an anycast IP (regional). Similar collateral model to AWS NLB. Choose this if you already use GCP.
## Architecture
```mermaid
flowchart LR
SENDER["Sender<br/>nak + udp_nostr_send.py"]
NPOOL["Nostr relay pool<br/>wss://relay.damus.io etc."]
subgraph AWS["AWS Global Accelerator"]
ANYCAST["Anycast IPs<br/>from AWS pool<br/>UDP listener"]
BACKEND["Backend target group<br/>relay host:8889"]
end
subgraph RelayHost["Relay Host - EC2 or elsewhere"]
NRELAY["UDP Nostr receiver<br/>udp_nostr_recv.py<br/>port 8889"]
ADVERT["Advert publisher<br/>kind 37195<br/>re-publishes on endpoint change"]
end
SENDER -- "1. nak req -k 37195 -a relay_npub" --> NPOOL
NPOOL -- "2. advert: udp endpoint<br/>anycast_ip:port" --> SENDER
SENDER -- "3. single UDP datagram<br/>no handshake" --> ANYCAST
ANYCAST -- "4. routes over AWS backbone" --> BACKEND
BACKEND --> NRELAY
ADVERT -- "publishes endpoint" --> NPOOL
style ANYCAST fill:#4a4,color:#fff
style NRELAY fill:#48a,color:#fff
style ADVERT fill:#a84,color:#fff
```
### What runs where
| Component | Where | Purpose |
|-----------|-------|---------|
| AWS Global Accelerator | AWS | Anycast UDP listener, routes to backend, provides range-level collateral IP |
| UDP Nostr receiver ([`src/udp_nostr_recv.py`](../src/udp_nostr_recv.py)) | EC2 or any reachable host | The relay data plane (unchanged) |
| Advert publisher ([`plans/udp_nostr_fips_discovery.md`](udp_nostr_fips_discovery.md) Step 1) | Relay host | Publishes kind 37195 with the accelerator's anycast endpoint |
| Sender resolver + sender | Sender's machine | From FIPS plan: resolve npub → endpoint, send UDP datagram |
### What changes vs. a bare UDP Nostr relay
| Component | Bare relay | This plan |
|---|---|---|
| UDP listener | Direct on host IP | Behind AWS Global Accelerator anycast IP |
| Client-facing IP | Host's IP (low collateral) | AWS anycast IP (range-level collateral) |
| Endpoint discovery | Hardcoded IP | FIPS kind 37195 advert, npub is stable address |
| IP mobility | Static | Backend can change without changing client IP; or relay moves to new accelerator + re-advertises |
| Routing | Direct path | Enters at AWS edge, traverses AWS backbone |
### What this gains
1. **Range-level collateral.** The anycast IP is in AWS's IP space. Blocking it by CIDR range breaks millions of AWS-hosted services. Regimes that block cloud ranges face extreme collateral damage; regimes that block by specific IP can target you, but must first identify the IP (which moves with mobility).
2. **Anycast hardening.** The anycast IP is advertised from multiple AWS edge locations. A regime must block it at all edges or accept partial reachability. This is harder than blocking a unicast IP in one location.
3. **Backend mobility without client change.** Global Accelerator lets you swap the backend target (your relay host) without changing the client-facing anycast IP. The relay can move to a new EC2 instance, a new region, or even a non-AWS host, and the sender never notices — the anycast IP stays the same.
4. **AWS backbone transit.** Traffic enters the AWS network at the edge and traverses AWS's private backbone to the backend. The relay host's actual IP is not visible to the sender or to border DPI at the sender's side — only the AWS anycast IP is visible.
5. **No-handshake preserved.** The UDP datagram passes through the accelerator transparently. No TLS, no handshake, no protocol fingerprint. The accelerator is a pass-through UDP forwarder, not a proxy that terminates connections.
### What it costs
1. **AWS Global Accelerator pricing.** Fixed hourly charge (~$0.025/hr ≈ $18/month) plus data transfer premiums (~$0.025/GB for standard, more for cross-region). For a low-bandwidth Nostr relay (events are <1472 bytes each), data costs are negligible. The fixed cost is the main expense.
2. **AWS account required.** The operator needs an AWS account. This is a KYC/identity consideration — AWS knows who you are. Mitigation: the relay host (backend) can be outside AWS; only the accelerator front-end is on AWS. But the accelerator is tied to an AWS account.
3. **No per-IP collateral.** As discussed: the anycast IP is dedicated to your accelerator. A regime that identifies and blocks just that IP breaks only your relay. The defense is range-level collateral + mobility, not per-IP sharing.
4. **UDP pass-through limitations.** Global Accelerator forwards UDP datagrams but does not guarantee delivery (UDP is unreliable). If a datagram is lost between the edge and the backend, it's gone. This is already the UDP Nostr model (fire-and-forget), so it's not a new limitation.
5. **Potential for AWS to terminate the account.** AWS could shut down the accelerator if they receive abuse complaints. Mitigation: the relay is a passive receiver that drops invalid events silently — there's nothing for AWS to detect as abuse. The relay doesn't send responses, doesn't host content, doesn't serve requests. It's a black hole that occasionally stores a valid Nostr event.
## Implementation steps
### Step 1 — Set up the relay backend
Run the existing UDP Nostr receiver on a host reachable from AWS. This can be:
- An EC2 instance in a public subnet (simplest)
- An EC2 instance in a private subnet with a NAT gateway (hides the backend IP)
- A non-AWS host with a public IP (the accelerator routes to it over the internet)
```bash
python3 src/udp_nostr_recv.py 0.0.0.0 8889
```
The backend must be reachable on UDP 8889 from the Global Accelerator's routing. If using EC2, configure the security group to allow inbound UDP 8889 from anywhere (the accelerator's traffic comes from AWS internal ranges, but allowing from anywhere is simpler and doesn't hurt — the relay drops invalid events silently).
**Deliverable:** running `udp_nostr_recv.py` on a backend host, reachable on UDP 8889.
### Step 2 — Create the AWS Global Accelerator
1. In the AWS Console (or via CLI/terraform), create a Global Accelerator.
2. Add a **UDP listener** on port 8889 (or 443 if you want to blend with QUIC — see open question 3).
3. Create an **endpoint group** in one or more AWS regions.
4. Add the relay backend as an **endpoint** (by IP address or ALB/NLB ARN if fronting an NLB).
5. Configure a **health check** (UDP health checks are supported — the accelerator sends a UDP probe and expects a response; since the relay drops invalid events silently, use a custom health check endpoint or set the health check to TCP on a separate port if the backend also runs a TCP service).
**Note on health checks:** Global Accelerator requires healthy endpoints to route traffic. The UDP Nostr receiver drops invalid packets silently (no response), which means a standard UDP health check will fail. Options:
- (a) Run a separate TCP health check endpoint on the backend (e.g., a simple TCP listener on port 8888 that accepts connections). The accelerator checks TCP 8888 for health and routes UDP 8889 to the same backend.
- (b) Add a minimal UDP health check response to the relay: if the accelerator sends a specific probe byte, respond with a single byte. This is a tiny addition to `udp_nostr_recv.py` and does not compromise the no-handshake property (the probe is not a Nostr event, and the response is a single byte, not a protocol handshake).
- (c) Use an NLB as the backend target, and let the NLB's health check handle it.
**Recommended:** option (b) — add a 3-line UDP health check responder to the receiver. The accelerator sends a probe; the receiver responds with a single byte. This is not a handshake (it doesn't reveal that the server is a Nostr relay — any UDP server could respond to a probe), and it keeps the setup simple.
**Deliverable:** running Global Accelerator with a UDP listener routing to the relay backend. Document the anycast IPs.
### Step 3 — Verify end-to-end through the accelerator
```bash
# Get the accelerator's anycast IPs from AWS console
ANYCAST_IP=<accelerator_anycast_ip>
# Send a test event
echo '<signed_event_json>' | python3 src/udp_nostr_send.py $ANYCAST_IP 8889
# Verify it arrived at the backend
# (check the receiver's output)
```
Also verify from a different geographic location (the anycast IP routes to the nearest edge, so testing from one location only exercises one edge).
**Deliverable:** `src/test_accelerator_relay.sh` — sends a test event to the accelerator IP and verifies delivery.
### Step 4 — Add mobility via FIPS discovery
Implement the relay-side advert publisher from [`plans/udp_nostr_fips_discovery.md`](udp_nostr_fips_discovery.md) Step 1 (`udp_nostr_relay_advertise.sh`):
1. Publishes a kind 37195 advert with the **accelerator's anycast IP** and port as the UDP endpoint.
2. Re-publishes on a timer (every 30 min) to keep the advert fresh.
3. Uses a distinct `d` tag (e.g. `udp-nostr-v1`) so FIPS nodes don't try to FMP-handshake it.
The key insight: **the advert points at the accelerator's anycast IP, not the backend's IP.** The backend can change without changing the advert. The advert only changes if the accelerator itself is recreated (e.g. AWS account terminated and relay moves to a new accelerator).
Implement the sender-side resolver and one-shot sender from the FIPS plan Steps 34.
**Deliverable:** the files listed in [`plans/udp_nostr_fips_discovery.md`](udp_nostr_fips_discovery.md) file summary.
### Step 5 — Backend mobility: swap backends without changing the client IP
Document and test the procedure for moving the backend:
1. Start a new relay backend (new EC2 instance, new region, or new host).
2. In the Global Accelerator endpoint group, add the new backend as a new endpoint.
3. Wait for the new endpoint to pass health checks.
4. Remove the old endpoint from the endpoint group.
5. Traffic now routes to the new backend. The anycast IP is unchanged. The advert is unchanged. Senders see no disruption.
This is the **zero-downtime mobility** path: the relay moves without any sender-visible change. This is the main advantage of the cloud-LB approach over bare-IP hosting.
**Deliverable:** `docs/backend_mobility.md` — step-by-step procedure for swapping backends.
### Step 6 — Full mobility: moving to a new accelerator
If the AWS account is compromised or the accelerator's anycast IP is specifically blocked, the relay must move to a new accelerator (or a new cloud provider). This is the **full mobility** path:
1. Create a new Global Accelerator (or GCP Network LB, or Azure LB) in a new account/provider.
2. Start the relay backend behind the new accelerator.
3. Re-publish the kind 37195 advert with the new accelerator's anycast IP.
4. Senders pick up the new endpoint on their next resolve (within the advert expiration window).
This path has re-advertise latency (senders don't find the new endpoint until the advert propagates). Mitigation: short advert expiration (1530 min), frequent re-publish, sender-side caching.
**Deliverable:** `src/test_full_mobility.sh` — simulate a full move: stop old accelerator, start new one, re-advertise, verify sender follows.
### Step 7 — Document the combination
Add a section to [`README.md`](../README.md:1) (or a new `docs/cloud_lb_collateral.md`) explaining:
- The principle: cloud-LB IPs provide range-level collateral (not per-IP like Cloudflare), plus anycast hardening and backend mobility.
- The honest distinction between per-IP collateral (Cloudflare) and range-level collateral (AWS/GCP/Azure).
- The AWS Global Accelerator setup for UDP Nostr.
- How this composes with FIPS discovery for mobility.
- The tradeoffs (cost, AWS account KYC, no per-IP collateral, health check requirement).
- The residual risks (targeted IP blocking, AWS account termination, range-blocking by determined regimes).
**Deliverable:** new documentation section.
## Open questions / decisions
1. **AWS Global Accelerator vs. NLB.** Global Accelerator provides anycast + backend mobility but costs more (~$18/month fixed + data premiums). NLB is cheaper but unicast (no anycast) and per-region. Which is the right default? Recommendation: Global Accelerator for production (anycast + mobility are worth the cost); NLB for testing/development.
2. **Health check approach.** The relay drops invalid packets silently, so standard UDP health checks fail. Options: (a) separate TCP health check port, (b) minimal UDP probe response added to the receiver, (c) NLB as intermediary. Option (b) is recommended — does it compromise the no-handshake property? Arguably not (a single-byte probe response is not a protocol handshake), but it's worth a deliberate decision.
3. **UDP port 443 vs. 8889.** Port 443 blends with QUIC/HTTP3 traffic (more cover, more collateral). Port 8889 is distinctive but avoids conflicts. Global Accelerator supports any port. Recommendation: 443 if blending with QUIC is desired; 8889 if simplicity is preferred. See [`docs/udp_nostr_relay_plan.md`](../docs/udp_nostr_relay_plan.md:151) for the port-443 rationale.
4. **Multi-cloud for resilience.** Should the relay maintain accelerators on multiple cloud providers (AWS + GCP) simultaneously, advertising both endpoints in the kind 37195 advert? This would mean blocking the relay requires blocking IPs in multiple cloud providers' ranges. Higher cost, higher resilience. Worth planning as a follow-up?
5. **AWS account KYC risk.** AWS knows the account holder's identity. If the relay is targeted by a regime that can pressure AWS, the account could be terminated. Mitigation: the backend can be outside AWS; only the accelerator is on AWS. But the accelerator is tied to an account. Is this an acceptable risk, or should we explore anonymous cloud accounts / crypto-paid cloud providers?
6. **Does the accelerator's anycast IP actually provide enough collateral?** The IP is dedicated to your accelerator, not shared with other customers. The collateral is range-level (blocking AWS's IP space). Is range-level collateral sufficient for the threat model, or do we need per-IP sharing (which would require Cloudflare Spectrum or similar — a different plan)?
## What this does NOT do
- It does **not** provide per-IP collateral like Cloudflare. The anycast IP is dedicated to your accelerator. The collateral is at the cloud provider's IP-range level.
- It does **not** protect against targeted single-IP blocking. If the regime identifies your specific anycast IP and blocks just that IP, only your relay is affected. Mobility (moving to a new accelerator) is the response.
- It does **not** change the UDP Nostr protocol. The datagram is still a single signed event, no handshake, no response (except the optional health-check probe).
- It does **not** hide the relay's IP from the adversary. The anycast IP is public. The backend's IP is hidden from the sender (traffic enters at the AWS edge), but AWS and the regime can see the backend IP in AWS's internal routing.
- It does **not** provide anonymity for the sender. The sender's IP is visible to the accelerator (and thus to AWS). Use Superball ([`plans/udp_nostr_superball.md`](udp_nostr_superball.md)) for sender location privacy.
- It does **not** solve the sender's *discovery* censorship problem. The sender's lookup of the kind 37195 advert is a WSS handshake. Mitigations are in [`plans/udp_nostr_fips_discovery.md`](udp_nostr_fips_discovery.md).
## File summary
| File | Status | Purpose |
|------|--------|---------|
| AWS Global Accelerator (UDP listener) | new (AWS) | Anycast UDP front-end, range-level collateral IP |
| `src/udp_nostr_recv.py` | existing (minor mod) | Relay: add optional UDP health-check probe response (3 lines) |
| `src/test_accelerator_relay.sh` | new | Send test event through accelerator, verify delivery |
| `docs/backend_mobility.md` | new | Step-by-step: swap backends without changing client IP |
| `src/test_full_mobility.sh` | new | Simulate full move: new accelerator, re-advertise, verify |
| `docs/cloud_lb_collateral.md` (or README section) | new | Document the cloud-LB collateral + mobility combination |
| `src/udp_nostr_relay_advertise.sh` | from FIPS plan | Relay: publish kind 37195 endpoint advert (anycast IP) |
| `src/udp_nostr_resolve.py` | from FIPS plan | Sender: fetch relay's current UDP endpoint by npub |
| `src/udp_nostr_send_via_npub.sh` | from FIPS plan | Sender: one-shot resolve + send |
| `src/udp_nostr_send.py` | existing | Sender: unchanged sender |
## Relationship to other plans
| Layer | Plan | Role |
|---|---|---|
| IP hardening — cloud-LB collateral | **this plan** (AWS Global Accelerator) | Range-level collateral + anycast + backend mobility |
| IP hardening — discovery mobility | [`plans/udp_nostr_fips_discovery.md`](udp_nostr_fips_discovery.md) | npub → current endpoint; relay can move accelerators |
| Data plane — no handshake | UDP Nostr core ([`docs/no_handshake.md`](../docs/no_handshake.md)) | DPI fingerprinting and active probing fail |
| Sender location privacy | [`plans/udp_nostr_superball.md`](udp_nostr_superball.md) | Hide sender's IP via multi-hop mixing |
| Final delivery | Nostr relays (existing) | Public, durable, queryable |
The full stack: a sender looks up the relay by npub (FIPS discovery), sends a no-handshake UDP datagram to the relay's AWS Global Accelerator anycast IP (this plan + core), optionally via Superball for sender privacy, and the event lands on public Nostr relays for the world to read.
+154
View File
@@ -0,0 +1,154 @@
# Plan: UDP Nostr + FIPS Discovery (Combination 1)
## Goal
Combine UDP Nostr's no-handshake transmission with FIPS's Nostr-mediated endpoint discovery, so that:
- The **relay** can move IPs freely and re-advertise its current UDP endpoint on Nostr (kind 37195 advert). Its npub is the stable address.
- The **sender** looks up the relay's current endpoint by npub, then sends a single raw UDP datagram — no FIPS daemon, no Noise handshake, no connection state.
- The no-handshake property of UDP Nostr is **fully preserved** on the data plane. FIPS is used only for discovery, never for transport.
## Architecture
```
Sender Relay
│ │
│ 1. nak req -k 37195 -d fips-overlay-v1 │
│ -a <relay_npub> ──────────────────► │ Nostr relay pool
│ advert │ (wss://relay.damus.io, etc.)
│ ◄──────────────────────────────────── │
│ endpoints: [{udp, 203.0.113.45:8889}] │
│ │
│ 2. extract udp endpoint from advert │
│ │
│ 3. nak event -k 1 -c "..." --sec <key> │
│ | python3 src/udp_nostr_send.py │
│ 203.0.113.45 8889 │
│ ──────── single UDP datagram ────────► │ src/udp_nostr_recv.py
│ (no handshake) │ (4-line listener)
│ │
│ when IP changes:
│ relay re-publishes kind 37195
│ with new endpoint
```
### What runs where
| Component | Runs FIPS? | Runs UDP Nostr? | Role |
|-----------|:----------:|:---------------:|------|
| Sender | No | Yes (send) | Fetches advert, sends raw UDP datagram |
| Relay | Yes (discovery only) | Yes (recv) | Publishes kind 37195 advert, listens for UDP datagrams |
| Nostr relays | — | — | Carry the kind 37195 adverts (existing public relays) |
The relay does **not** need to run the full FIPS mesh. It only needs the discovery/advert-publishing piece. Two implementation paths for the relay side:
- **Path A (minimal):** A small script that publishes kind 37195 adverts using `nak` and runs the existing 4-line [`src/udp_nostr_recv.py`](../src/udp_nostr_recv.py). No FIPS daemon at all — just re-use FIPS's advert *format* and the public Nostr relay pool.
- **Path B (full FIPS):** Run the FIPS daemon with `transports.udp.advertise_on_nostr: true` and `node.discovery.nostr.advertise: true`, but **do not accept FMP connections** — only use FIPS for advert publishing + NAT-traversal + auto-re-advertise on IP change. The UDP Nostr listener runs alongside it on a separate port.
Path A is simpler and keeps the relay side dependency-free. Path B gives you automatic re-advertisement on IP change and STUN-based NAT traversal for free. Start with Path A, upgrade to Path B if the relay is behind NAT.
## Implementation steps
### Step 1 — Relay: publish endpoint advert (Path A, minimal)
Write a script `udp_nostr_relay_advertise.sh` that:
1. Takes the relay's Nostr secret key, the UDP port it listens on, and its public IP (or auto-detect via an echo service).
2. Constructs a kind 37195 event with:
- `d` tag: `fips-overlay-v1`
- `protocol` tag: `fips-overlay-v1`
- `version` tag: `1`
- `expiration` tag: now + 3600
- content JSON: `{"identifier":"fips-overlay-v1","version":1,"endpoints":[{"transport":"udp","addr":"<ip>:<port>"}]}`
3. Signs and publishes with `nak event -k 37195 --sec <key>` piped to `nak publish` (or `nostcat`).
4. Re-runs on a timer (e.g. every 30 min, or on IP-change detection) to keep the advert fresh.
**Deliverable:** `udp_nostr_relay_advertise.sh` in this repo.
### Step 2 — Relay: run the UDP Nostr listener
No changes needed — the existing [`src/udp_nostr_recv.py`](../src/udp_nostr_recv.py) already does this. Document that the relay runs:
```bash
python3 src/udp_nostr_recv.py 0.0.0.0 8889
```
alongside the advert publisher from Step 1.
**Deliverable:** updated README section showing the relay-side setup.
### Step 3 — Sender: resolve endpoint from advert
Write a script `udp_nostr_resolve.sh` (or `.py`) that:
1. Takes the relay's npub and an optional list of Nostr relays (default: the FIPS defaults — `wss://relay.damus.io`, `wss://nos.lol`, `wss://offchain.pub`).
2. Runs `nak req -k 37195 -d fips-overlay-v1 -a <relay_npub>` against the relays.
3. Parses the returned event's content JSON, extracts the first `udp` endpoint's `addr` field.
4. Prints `ip port` to stdout (suitable for piping into `src/udp_nostr_send.py`).
**Deliverable:** `udp_nostr_resolve.py` in this repo.
### Step 4 — Sender: one-shot send-via-discovery command
Write a wrapper `src/udp_nostr_send_via_npub.sh` that chains resolve + send:
```bash
#!/bin/bash
# Usage: echo "hello" | src/udp_nostr_send_via_npub.sh <relay_npub> [relay_url ...]
NPUB=$1; shift
ENDPOINT=$(python3 src/udp_nostr_resolve.py "$NPUB" "$@")
nak event -k 1 -c "$(cat)" --sec $(nak key generate) \
| python3 src/udp_nostr_send.py $ENDPOINT
```
**Deliverable:** `src/udp_nostr_send_via_npub.sh` in this repo.
### Step 5 — Test end-to-end
1. Start the relay: `src/udp_nostr_recv.py` + `src/udp_nostr_relay_advertise.sh` with a test key.
2. From a separate machine (or loopback), run `src/udp_nostr_send_via_npub.sh <relay_npub>` with a test message.
3. Verify the message arrives at the relay.
4. Change the relay's advertised IP (simulate move), re-publish, and verify the sender picks up the new endpoint on the next resolve.
**Deliverable:** updated [`src/test_local.sh`](../src/test_local.sh) or a new `src/test_discovery.sh`.
### Step 6 — Document the combination
Add a section to [`README.md`](README.md:1) (or a new `discovery.md`) explaining:
- The problem this solves (endpoint blocking / relay mobility).
- The architecture diagram above.
- That FIPS is used for discovery only — the data plane remains handshake-free.
- The residual exposure: the sender's Nostr relay lookup is a TCP/WSS handshake. Mitigations: cache adverts, use Tor for the lookup, or embed the endpoint in DNS (cross-reference [`docs/passive_sniffing_relay.md`](../docs/passive_sniffing_relay.md) Model B).
**Deliverable:** new documentation section.
## Open questions / decisions
1. **Advert kind reuse vs. new kind.** Reusing kind 37195 with `d=fips-overlay-v1` means FIPS nodes will see these adverts and may try to FMP-handshake the relay. Options:
- (a) Use a different `d` tag (e.g. `udp-nostr-v1`) so FIPS nodes ignore it. **Recommended** — cleanest separation.
- (b) Use a different kind entirely (e.g. a new application-defined replaceable kind). More work, less reuse.
- (c) Accept the stray handshake attempts; the relay just drops them (it's not running FMP). Noisy but harmless.
2. **Advert freshness vs. relay churn.** If the relay's IP changes but it can't re-publish (no connectivity to Nostr relays), senders will have a stale endpoint. Mitigation: short `expiration` (e.g. 15 min) + frequent re-publish. The sender falls back to cached adverts or fails gracefully.
3. **Sender-side relay-list bootstrapping.** The sender needs at least one Nostr relay URL to start the lookup. This is a small bootstrap problem — solved by shipping defaults (the FIPS list) or embedding a relay URL in a DNS TXT record / the relay's npub profile (kind 0).
## What this does NOT do
- It does **not** run FIPS on the sender. The sender is still just `nak` + 4 lines of Python.
- It does **not** add a handshake to the data plane. The UDP datagram is still fire-and-forget, signature-only.
- It does **not** hide the sender's lookup of the relay endpoint. That lookup is a normal Nostr REQ over WSS (or Tor, if the sender chooses). This is a separate problem from the data-plane censorship resistance.
- It does **not** provide two-way communication. The sender cannot receive replies unless the relay knows where to send them (which would require a handshake or a return-address in the event).
## File summary
| File | Status | Purpose |
|------|--------|---------|
| `src/udp_nostr_relay_advertise.sh` | new | Relay: publish kind 37195 endpoint advert |
| `src/udp_nostr_resolve.py` | new | Sender: fetch relay's current UDP endpoint by npub |
| `src/udp_nostr_send_via_npub.sh` | new | Sender: one-shot resolve + send |
| `src/udp_nostr_recv.py` | existing | Relay: unchanged 4-line listener |
| `src/udp_nostr_send.py` | existing | Sender: unchanged 4-line sender |
| `src/test_discovery.sh` | new | End-to-end test |
| `README.md` | update | Document the discovery combination |
+261
View File
@@ -0,0 +1,261 @@
# Plan: UDP Nostr as First Hop into Superball (Combination A)
## Goal
Add a UDP listener to the Superball Thrower daemon so that Alice can send her kind 22222 Superball routing event as a single handshake-free UDP datagram directly to the first Thrower, instead of posting it to a Nostr relay over WSS.
This eliminates the handshake on the most dangerous hop — the one that directly exposes Alice's IP — while leaving the rest of the Superball chain (multi-hop mixing, delays, padding, audit tags, final relay posting) completely unchanged.
## Architecture
```
Alice Thrower A Thrower B Relay3
│ │ │ │
│ 1. Build Superball routing event │ │ │
│ (kind 22222, NIP-44 encrypted, │ │ │
│ routing instructions for A→B→relay) │ │ │
│ │ │ │
│ 2. Send as single UDP datagram │ │ │
│ ─────────────────────────────────────► │ │ │
│ (no handshake, no TLS, no TCP) │ │ │
│ │ │ │
│ UDP listener parses datagram, │ │
│ validates kind 22222 event, │ │
│ calls handleIncomingEvent() │ │
│ │ │ │
│ │ 3. Normal Superball │ │
│ │ processing: decrypt, │ │
│ │ delay, rewrap, │ │
│ │ forward via WSS ─────► │ │
│ │ │ 4. decrypt, delay, │
│ │ │ post Alice's │
│ │ │ signed event ──► │
│ │ │ (final relay) │
```
### What changes vs. standard Superball
| Component | Standard Superball | With UDP first hop |
|---|---|---|
| Alice → first Thrower | WSS to Nostr relay, Thrower picks it up via subscription | Single UDP datagram directly to Thrower |
| First Thrower → second Thrower | WSS (unchanged) | WSS (unchanged) |
| Last Thrower → final relay | WSS (unchanged) | WSS (unchanged) |
| Kind 22222 event format | Unchanged | Unchanged |
| NIP-44 encryption | Unchanged | Unchanged |
| Routing instructions | Unchanged | Unchanged |
| Thrower daemon | WSS-only listener | WSS listener + optional UDP listener |
The only change is **how the first Thrower receives the event** — UDP datagram instead of WSS subscription. Everything downstream is identical.
### What this gains
1. **No handshake on the first hop.** Alice's connection to the first Thrower has no SYN, no TLS ClientHello, no SNI, no recognizable protocol structure. DPI cannot fingerprint it. TCP RST injection is impossible (UDP has no RST). Active probing fails (the Thrower's response to an invalid event is indistinguishable from noise).
2. **Alice's IP is only seen by the first Thrower.** In standard Superball, Relay1 sees Alice's IP. With UDP, the first Thrower sees Alice's IP — but the first Thrower is a trusted privacy node, not a public relay. And the first Thrower is chosen by Alice, not by the network.
3. **No change to the Superball protocol.** The kind 22222 event, NIP-44 encryption, routing instructions, audit tags, and Thrower behavior are all unchanged. This is a transport-layer addition, not a protocol change.
### What it costs
1. **No store-and-forward on the first hop.** If the first Thrower is down, the datagram is lost. In standard Superball, Relay1 holds the event until the Thrower picks it up. Mitigation: Alice can send to multiple first-Thrower candidates, or retry.
2. **Alice needs to know the first Thrower's UDP endpoint.** In standard Superball, Alice posts to a relay and any Thrower monitoring that relay can pick it up. With UDP, Alice needs the Thrower's IP:port. This is a discovery problem — solvable with FIPS kind 37195 adverts (see [`plans/udp_nostr_fips_discovery.md`](udp_nostr_fips_discovery.md)), Thrower Info Documents (SUP-6), or out-of-band communication. For the minimal first step, out-of-band or hardcoded endpoints are fine.
3. **Size limit.** The kind 22222 event must fit in a single UDP datagram (1472 bytes payload). A multi-hop Superball with padding can exceed this. Mitigation: keep the first hop's routing event small (no padding on the first hop — padding is added by Throwers on subsequent hops), or use the `.bne` binary format from [`docs/binary_events.md`](../docs/binary_events.md) to save space.
## Implementation steps
### Step 1 — Add UDP listener to the Thrower daemon
Add a `UdpListener` class to [`thrower_daemon/daemon.js`](../../super_ball/thrower_daemon/daemon.js) (in the super_ball repo). It:
1. Creates a `dgram` UDP socket bound to a configurable port (default: 8889).
2. On receiving a datagram, parses it as UTF-8 JSON.
3. Validates it's a Nostr event:
- Has `kind === 22222`
- Has a valid `id` (SHA256 of the canonical event serialization)
- Has a valid `sig` (Schnorr signature verification against `pubkey`)
- Has a `p` tag matching this Thrower's pubkey
4. If valid, calls `this.eventProcessor.handleIncomingEvent(event)` — the same method the WebSocketManager calls.
5. If invalid, drops silently (no response — same fail-silent rule as the rest of the daemon).
The class structure mirrors `WebSocketManager`:
```javascript
const dgram = require('dgram');
class UdpListener {
constructor(config, logger, eventProcessor) {
this.config = config;
this.logger = logger;
this.eventProcessor = eventProcessor;
this.socket = null;
}
startListening() {
const port = this.config.get('udp.port') || 8889;
const publicKey = this.config.get('thrower.publicKey');
this.socket = dgram.createSocket('udp4');
this.socket.on('message', (msg, rinfo) => {
this.handleDatagram(msg, rinfo, publicKey);
});
this.socket.bind(port, '0.0.0.0', () => {
this.logger.info(`UDP listener started on port ${port}`);
});
}
async handleDatagram(msg, rinfo, publicKey) {
try {
const event = JSON.parse(msg.toString('utf8'));
// Validate kind, p tag, signature
if (event.kind !== 22222) return;
const pTags = (event.tags || []).filter(t => t[0] === 'p').map(t => t[1]);
if (!pTags.includes(publicKey)) return;
// Verify signature using nostr-tools validateEvent
if (!validateEvent(event) || !verifySignature(event)) return;
this.logger.info(`UDP: Received kind 22222 from ${rinfo.address}:${rinfo.port}`);
this.eventProcessor.handleIncomingEvent(event);
} catch (e) {
// Fail silently — drop invalid datagrams
}
}
stopListening() {
if (this.socket) {
this.socket.close();
this.socket = null;
this.logger.info('UDP listener stopped');
}
}
}
```
**Deliverable:** `UdpListener` class added to `daemon.js` in the super_ball repo.
### Step 2 — Wire UDP listener into the daemon startup
In `ThrowerDaemon`:
1. Constructor: instantiate `this.udpListener = new UdpListener(this.config, this.logger, this.eventProcessor)`.
2. `start()`: after `this.wsManager.startMonitoring()`, call `this.udpListener.startListening()` if UDP is enabled in config.
3. `stop()`: call `this.udpListener.stopListening()`.
Config addition in `config.json`:
```json
{
"udp": {
"enabled": true,
"port": 8889
}
}
```
Default: `enabled: false` (opt-in, like FIPS's discovery).
**Deliverable:** Updated `ThrowerDaemon` class and config schema.
### Step 3 — Add a UDP sender for Superball events
Write a sender script (in this repo, `src/`) that:
1. Reads a kind 22222 Superball routing event from stdin (JSON).
2. Sends it as a single UDP datagram to the first Thrower's IP:port.
3. Sets the DF flag (like [`src/udp_nostr_send.py`](../src/udp_nostr_send.py)) to enforce single-packet delivery.
This is essentially the existing `udp_nostr_send.py` — it already does exactly this. The only difference is the content is a kind 22222 event instead of a kind 1 event. **No new code needed**`udp_nostr_send.py` is content-agnostic; it sends whatever JSON it reads from stdin.
**Deliverable:** Documentation showing how to pipe a Superball event through the existing sender.
### Step 4 — Build a Superball-over-UDP builder script
Write a script (in this repo, `src/`) that:
1. Takes Alice's content message, her secret key, the first Thrower's pubkey, and the first Thrower's UDP endpoint (IP:port).
2. Builds the final kind 1 event (Alice's signed message).
3. Builds the routing instructions for the first Thrower (relays, delay, next hop or final posting).
4. Wraps everything as a kind 22222 event encrypted to the first Thrower's pubkey (NIP-44).
5. Pipes the kind 22222 event to `udp_nostr_send.py` targeting the Thrower's UDP endpoint.
This is the "builder" role from the Superball protocol. The super_ball repo has a web-based builder (`web/superball.html`); this would be a CLI version that outputs to UDP instead of WSS.
**Deliverable:** `src/superball_udp_send.py` (or `.sh` using `nak`).
### Step 5 — Test end-to-end
1. Start a Thrower daemon with UDP enabled (`udp.enabled: true`, `udp.port: 8889`).
2. Build a Superball event targeting that Thrower.
3. Send it via UDP: `cat superball_event.json | python3 src/udp_nostr_send.py 127.0.0.1 8889`.
4. Verify the Thrower daemon receives, decrypts, and processes it (check logs).
5. If multi-hop: verify the event forwards to the next Thrower / final relay.
**Deliverable:** `src/test_superball_udp.sh` test script.
### Step 6 — Document the combination
Add a section to the super_ball repo's README (or a new `UDP_FIRST_HOP.md`) explaining:
- The problem: standard Superball's first hop is WSS (handshake visible to DPI).
- The solution: send the first hop as a UDP datagram (no handshake).
- The architecture diagram above.
- How to enable UDP on the Thrower daemon.
- How to build and send a Superball over UDP.
- The tradeoffs (no store-and-forward on first hop, size limit, discovery).
- Future: FIPS discovery for Thrower UDP endpoints (cross-reference [`plans/udp_nostr_fips_discovery.md`](../plans/udp_nostr_fips_discovery.md)).
**Deliverable:** Documentation in the super_ball repo.
## Size constraint analysis
A kind 22222 Superball routing event for the first hop contains:
| Component | Approximate size |
|---|---|
| Event JSON structure (kind, pubkey, created_at, id, sig, tags) | ~200 bytes |
| NIP-44 encrypted content (routing instructions + inner event) | varies |
| Inner event (Alice's kind 1, or next routing event) | ~300-500 bytes |
| Routing instructions JSON | ~100-200 bytes |
| NIP-44 encryption overhead (~10% + 48 bytes) | ~50-100 bytes |
| **Total (single-hop, no padding)** | **~650-1000 bytes** |
| **Total (two-hop, with padding)** | **~1000-1400 bytes** |
A single-hop Superball (Alice → Thrower → final relay) fits comfortably in 1472 bytes. A two-hop Superball with padding is borderline. Mitigations:
- Keep the first hop's routing event padding-free (padding is added by Throwers on subsequent hops, not by Alice).
- Use the `.bne` binary format ([`docs/binary_events.md`](../docs/binary_events.md)) for the inner event to save ~200 bytes.
- For larger events, send the first hop over WSS (fall back to standard Superball).
## Open questions / decisions
1. **Where does the code live?** The UDP listener goes in the super_ball repo (it's a Thrower daemon feature). The builder/sender scripts go in this repo (they're sender-side tools). Is that the right split, or should everything go in one repo?
2. **Signature verification on UDP.** The Thrower must verify the kind 22222 event's signature before processing. `nostr-tools` provides `validateEvent` and `verifySignature` — but these are synchronous and may be slow under flood. Should we add rate-limiting on the UDP listener (per-IP or global)? The existing daemon doesn't rate-limit WSS events, but UDP is easier to flood.
3. **Discovery.** For the minimal first step, Alice knows the first Thrower's UDP endpoint out-of-band. Should we plan FIPS kind 37195 adverts for Thrower UDP endpoints as a follow-up? (This would be Combination C from the analysis.)
4. **Multi-Thrower redundancy.** Should Alice send the same Superball to multiple first-Thrower UDP endpoints simultaneously (like Superball's SUP-4 multi-path)? This would mitigate the no-store-and-forward risk.
## What this does NOT do
- It does **not** change the Superball protocol. Kind 22222, NIP-44, routing instructions, audit tags, and Thrower behavior are all unchanged.
- It does **not** eliminate handshakes on subsequent hops. Thrower-to-Thrower and Thrower-to-relay communication is still WSS. Only the first hop (Alice → first Thrower) is UDP.
- It does **not** solve discovery. Alice needs to know the first Thrower's UDP endpoint. FIPS adverts are a separate follow-up.
- It does **not** provide two-way communication. Alice cannot receive replies via UDP. Audit tags still require Alice to monitor relays via WSS (or Tor).
## File summary
| File | Repo | Status | Purpose |
|------|------|--------|---------|
| `thrower_daemon/daemon.js` | super_ball | modify | Add `UdpListener` class, wire into `ThrowerDaemon` |
| `thrower_daemon/config.example.json` | super_ball | modify | Add `udp.enabled` and `udp.port` fields |
| `src/superball_udp_send.py` | udp_nostr | new | CLI builder: construct Superball, send via UDP |
| `src/test_superball_udp.sh` | udp_nostr | new | End-to-end test |
| `UDP_FIRST_HOP.md` | super_ball | new | Document the UDP first-hop combination |
## Relationship to other plans
This plan is the **minimal first step** toward the full vision (Combination C). The follow-up plan ([`plans/udp_nostr_fips_discovery.md`](udp_nostr_fips_discovery.md)) adds FIPS kind 37195 adverts so Throwers can advertise their UDP endpoints and move IPs freely. Together they form the full stack:
| Layer | Plan | Role |
|---|---|---|
| Discovery | `udp_nostr_fips_discovery.md` | npub → current UDP endpoint (Throwers can move) |
| First hop | **this plan** | No-handshake UDP datagram to first Thrower |
| Mixing | Superball (existing) | Multi-hop location privacy with delays, padding, audit |
| Final delivery | Nostr relays (existing) | Public, durable, queryable by any Nostr client |
View File