329 lines
19 KiB
Markdown
329 lines
19 KiB
Markdown
# 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, 1–16 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 + 1–16 bytes PKCS#7 padding = **49–64 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 (1–16) | **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) | ~30–50% of all traffic |
|
||
| WireGuard | 1472 B (full MTU) | growing |
|
||
| DNS | 50–512 B | tiny by bytes, huge by packet count |
|
||
| VoIP / gaming | 100–300 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.
|