Files
udp_nostr/docs-ideas/encrypted_events.md
2026-08-12 16:34:22 -04:00

19 KiB
Raw Permalink Blame History

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

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) ≤ 1472padded_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
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:

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