- Minimal sender/receiver (4 lines of Python each) - nak + nc zero-code demo approach - GFW analysis: how each layer fails against UDP Nostr - Protocol hardening, MTU exploration, no-handshake analysis - Binary event format (.bne) spec - Relay integration plan
24 KiB
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) 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:
- A domain name
- A server with a public IP
- 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:
| 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 is a lightweight, authoritative-only DNS server. It does not do recursive resolution — it only answers queries for domains it is authoritative for.
Installation:
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:
# 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:
- Listens for UDP datagrams on port 53
- Parses the DNS query header to extract the question (subdomain)
- Extracts the base64 label
- Responds with a valid DNS response (NXDOMAIN)
- Decodes the event and injects it into the relay
Python example (conceptual):
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:
# 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:
# 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:
# /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)
// 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:
- Extracts the hostname:
${eventBase64}.yourdomain.com - Asks its DNS resolver for the IP
- DNS resolver queries your authoritative nameserver ← YOU RECEIVE THE EVENT
- Your server responds with NXDOMAIN or an IP
- Browser makes HTTPS request to the IP (fails or succeeds — doesn't matter)
Method 2: Fetch
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)
// 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)
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)
# 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:
- Requires no special network positioning — the DNS system delivers queries to your server automatically
- Provides content hiding — the event is encoded in the subdomain label
- Leverages existing infrastructure — DNS is universally allowed and cannot be blocked
- Is testable from a browser —
new Image()triggers a DNS lookup with no special APIs - Supports large events via multi-query splitting — 10 queries can deliver any single-packet event
- Works with the binary
.bneformat — small events fit within DNS label constraints - 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.