Files
udp_nostr/plans/encryption_implementation_plan.md
Laan Tungir 16f20d01c0 Add NIP-44 encrypted event support to UDP Nostr sender/receiver
- Add shared NIP-44 v2 crypto helpers (src/nip44.py, src/nip44.js) producing
  ephemeral_xonly_pubkey[32] || native_nip44_payload framing, interoperable
  with base64-decoded nak encrypt output in both directions
- Upgrade existing senders/receivers in place (no duplicate apps): plaintext
  remains the default; --relay-pubkey / --relay-secret (or env vars) select
  encrypted mode on the same programs and ports
- Receiver detects plaintext first, then falls back to encrypted framing via
  the 32-byte pubkey boundary and NIP-44 0x02 version byte, handling the
  leading-brace ephemeral pubkey collision
- Add src/udp_encrypt_send.sh nak+nc wrapper and src/test_udp_nostr.py suite
  (46 tests: plaintext, encrypted, mixed traffic, nak interop, collision,
  malformed/tampered packets, wrong keys, size budget, env secrets)
- Update README and encryption implementation plan
2026-08-23 09:50:59 -04:00

12 KiB
Raw Permalink Blame History

UDP Nostr Encryption Implementation Plan

Goal

Add encryption to the UDP Nostr sender/receiver so the wire carries opaque ciphertext instead of plaintext JSON events, while maintaining full backward compatibility with existing plaintext senders.

Design

Wire format (encrypted)

Use an unmodified NIP-44 v2 payload produced by nak encrypt, decoded from base64, and prepend the 32-byte x-only public key whose secret key was used for encryption:

UDP datagram (≤ 1472 bytes):
+---------------------------------------------------+
| ephem_pub  (32B) = fresh secp256k1 x-only pubkey  |
| version     (1B) = NIP-44 version 0x02            |
| nonce      (32B) = random                         |
| ciphertext (N B) = ChaCha20(padded_plaintext)     |
| mac        (32B) = HMAC-SHA256(nonce||ciphertext) |
+---------------------------------------------------+
  • Key derivation (verified against nak v0.19.4 and the paulmillr/nip44 reference):
    • conversation key = HKDF-Extract(salt=nip44-v2, IKM=ECDH x-coordinate of ephemeral_priv × relay_pub)
    • message keys = HKDF-Expand(PRK=conversation key, info=nonce, 76 bytes) → chacha_key[32] ‖ chacha_nonce[12] ‖ hmac_key[32]
    • ciphertext = IETF ChaCha20 (12-byte nonce, counter 0) over the padded plaintext
    • mac = HMAC-SHA256(hmac_key, nonce ‖ ciphertext)
    • padding: 2-byte big-endian length prefix ‖ plaintext ‖ zero padding, padded length = 32 for len ≤ 32, else chunk * (1 + floor((len-1)/chunk)) with chunk = 32 below 1 KB and powers of two / 8 above
  • nak compatibility: bytes 32 onward are exactly base64_decode(nak encrypt ...); the native NIP-44 payload is not modified
  • Self-contained UDP framing: native NIP-44 does not carry the sender pubkey, so the datagram prepends it for relay-side ECDH
  • Two-stage gate: verify the NIP-44 MAC before parsing or verifying the inner Nostr event
  • No custom outer version byte: the native NIP-44 0x02 version remains at the known offset 32

Backward compatibility: plaintext + encrypted on the same port

The relay accepts both formats on the same port. Detection uses JSON validation plus the known 32-byte public-key boundary, rather than relying solely on byte zero:

receive datagram
  │
  ├─ starts with '{' and parses as a valid plaintext Nostr event?
  │     YES → verify Schnorr signature → store event
  │
  └─ otherwise, encrypted structure is plausible?
        ├─ datagram length is at least 131 bytes
        ├─ bytes 0..31 are a valid x-only secp256k1 pubkey
        └─ byte 32 is native NIP-44 version 0x02
             YES → decrypt bytes 32..end using:
                    relay secret + prepended ephemeral pubkey
                    verify MAC
                    parse inner JSON event
                    verify Schnorr signature
                    store event
             NO  → drop silently

An ephemeral pubkey can begin with { (0x7b), but this no longer causes an encrypted datagram to be dropped. The receiver first attempts strict plaintext JSON/event validation; on failure, it falls back to encrypted framing and finds the NIP-44 version byte at the deterministic offset immediately after the 32-byte pubkey. Programmatic senders may still reject ephemeral pubkeys beginning with 0x7b as defense in depth, but correctness does not depend on sender-side screening and simple nak command lines remain supported.

Existing plaintext senders keep working unchanged. nak event ... | nc -u still works. Encrypted senders only need standard command-line tools to generate an ephemeral key, run nak encrypt, base64-decode its output, prepend the public key, and optionally pipe the resulting bytes to nc.

Current State

File What it does Needs change?
src/udp_nostr_send.py Reads JSON from stdin, sends as raw UDP Yes — add encrypted send mode
src/udp_nostr_recv.py Receives UDP, prints decoded JSON Yes — add decryption, handle both formats
src/udp_nostr_send.js Builds demo event, sends as JSON Yes — add encrypted send mode
src/udp_nostr_recv.js Receives UDP, parses JSON Yes — add decryption, handle both formats

Implementation Steps

Step 1: Create src/nip44.py — NIP-44 encrypt/decrypt library

A Python module implementing NIP-44 version 2 encryption and decryption. Uses coincurve for secp256k1 ECDH and cryptography for HKDF-SHA256, ChaCha20, and HMAC-SHA256.

Functions:

def encrypt(plaintext: bytes, relay_pubkey_hex: str) -> bytes:
    """Return ephem_pub(32) || native_nip44_payload.

    The native payload is version(1) || nonce(32) || ciphertext(N) || mac(32),
    matching base64_decode(nak encrypt ...) byte-for-byte.
    """

def decrypt(datagram: bytes, relay_secret_hex: str) -> bytes:
    """Decrypt the self-contained UDP framing.

    1. Parse ephem_pub from bytes 0..31.
    2. Require native NIP-44 version 0x02 at byte 32.
    3. Parse nonce, ciphertext, and MAC from bytes 33..end.
    4. Derive the conversation key with relay_priv and ephem_pub.
    5. Verify HMAC-SHA256 over nonce || ciphertext in constant time.
    6. Decrypt, unpad, and return the inner plaintext event.
    """

Dependencies:

  • coincurve — secp256k1 ECDH (unhashed x-coordinate)
  • cryptography — HKDF-SHA256, ChaCha20, HMAC-SHA256

Step 2: Update src/udp_nostr_recv.py — handle both plaintext and encrypted

Changes:

  1. Add --relay-secret argument (hex-encoded private key, optional).
  2. On receiving a datagram:
    • If it starts with {, attempt strict plaintext JSON/event parsing and signature verification.
    • If plaintext validation fails, do not immediately drop it; continue to encrypted detection.
    • Treat it as encrypted only when its minimum length is valid, bytes 0..31 form a valid x-only secp256k1 pubkey, and byte 32 is NIP-44 version 0x02.
    • Pass the complete datagram to nip44.decrypt(), which uses the prepended pubkey and native payload.
    • On framing, key, version, padding, or MAC failure: drop silently.
    • On successful decryption: parse the inner JSON, verify its Schnorr signature, and print/store it.
    • If no --relay-secret was supplied, encrypted datagrams cannot be processed and are dropped.

Usage:

# Plaintext mode (unchanged)
python3 src/udp_nostr_recv.py 0.0.0.0 8889

# Encrypted mode (new)
python3 src/udp_nostr_recv.py 0.0.0.0 8889 --relay-secret <hex>

Step 3: Update src/udp_nostr_send.py — add encrypted send mode

Changes:

  1. Add --relay-pubkey argument (hex-encoded relay public key, optional).
  2. If --relay-pubkey is provided:
    • Read signed event JSON from stdin.
    • Generate a fresh ephemeral keypair; programmatic implementations may regenerate when the x-only pubkey begins with 0x7b as defense in depth.
    • Produce a standards-compatible native NIP-44 payload.
    • Send ephem_pub(32) || version(1) || nonce(32) || ciphertext || mac(32) as raw bytes.
  3. If --relay-pubkey is not provided, send plaintext JSON unchanged.

Usage:

# Plaintext mode (unchanged)
nak event -k 1 -c "Hello!" --sec <sec> | python3 src/udp_nostr_send.py 127.0.0.1 8889

# Encrypted mode (new)
nak event -k 1 -c "Hello!" --sec <sec> \
  | python3 src/udp_nostr_send.py 127.0.0.1 8889 --relay-pubkey <relay_pub_hex>

Step 4: Create src/udp_encrypt_send.sh — command-line wrapper for nak and nc users

The shell path uses nak for all cryptography. nak encrypt returns a base64-encoded native NIP-44 payload; the wrapper decodes it without alteration and prepends the matching ephemeral public key:

#!/bin/bash
# Usage: nak event -k 1 -c "Hello!" --sec <sec> | src/udp_encrypt_send.sh <ip> <port> <relay_pub_hex>
set -euo pipefail

IP=$1; PORT=$2; RELAY_PUB=$3

# Generate ephemeral keypair
EPH_SEC=$(nak key generate)
EPH_PUB=$(nak key public --sec "$EPH_SEC")

# Read event from stdin. nak output is base64(version || nonce || ct || mac).
EVENT=$(cat)
NIP44_B64=$(nak encrypt --sec "$EPH_SEC" -p "$RELAY_PUB" "$EVENT")
{
    printf "%s" "$EPH_PUB" | xxd -r -p
    printf "%s" "$NIP44_B64" | base64 -d
} | nc -u -w1 "$IP" "$PORT"

Usage:

nak event -k 1 -c "Hello!" --sec <sec> \
  | src/udp_encrypt_send.sh laantungir.net 443 <relay_pub_hex>

Step 5: Update JavaScript scripts

src/udp_nostr_send.js:

  • Add --relay-pubkey argument.
  • Use @noble/secp256k1 for ECDH and Node.js crypto for HKDF, ChaCha20, and HMAC.
  • Emit the same ephem_pub(32) || native_nip44_payload framing as the nak command-line path.
  • Optionally regenerate an ephemeral key whose public key starts with 0x7b; receiver correctness must not depend on this screening.

src/udp_nostr_recv.js:

  • Add --relay-secret argument.
  • Attempt strict plaintext event parsing first when the datagram starts with {.
  • If plaintext validation fails, fall back to encrypted detection using minimum length, a valid x-only pubkey in bytes 0..31, and NIP-44 version 0x02 at byte 32.
  • Decrypt and authenticate the native NIP-44 payload in bytes 32..end when --relay-secret is provided.

Step 6: Testing

Test What to verify
Plaintext round-trip Existing nak event | nc -u still works
nak interoperability Datagram built as `ephem_pub
Encrypted round-trip nak event | udp_encrypt_send.sh → receiver decrypts and prints event
Mixed traffic Plaintext and encrypted events on the same port are both processed correctly
Leading-brace collision Encrypted packet whose ephemeral pubkey starts with 0x7b fails plaintext validation, falls back to encrypted detection, and decrypts successfully
Tampered ciphertext MAC failure → silent drop before inner signature verification
Wrong relay key NIP-44 MAC/decryption failure → silent drop
Structural rejection Truncated payload, invalid x-only pubkey, or non-0x02 byte at offset 32 → silent drop

Files to Create

File Purpose
src/nip44.py NIP-44 v2 encrypt/decrypt in Python
src/udp_encrypt_send.sh Shell wrapper: nak encrypt + base64 decode + ephem pubkey prepend + nc send

Files to Modify

File Change
src/udp_nostr_send.py Add --relay-pubkey arg. If provided, import nip44, encrypt before send.
src/udp_nostr_recv.py Add --relay-secret; validate plaintext first, then detect encrypted framing via the 32-byte pubkey boundary and NIP-44 0x02 at offset 32.
src/udp_nostr_send.js Add --relay-pubkey arg, NIP-44 encryption before send.
src/udp_nostr_recv.js Add --relay-secret arg, first-byte discriminator, decryption.

Dependencies

Python

  • cryptography — secp256k1 ECDH (unhashed x-coordinate), ChaCha20, HMAC-SHA256 (HKDF-Extract/Expand implemented directly with HMAC-SHA256 to match the NIP-44 reference exactly; coincurve turned out to be unnecessary)

JavaScript

  • None — Node.js built-in crypto module provides ECDH secp256k1, ChaCha20, and HMAC-SHA256 (HKDF-Expand implemented directly to match the reference)

Implementation status

Implemented and verified (all 46 tests in src/test_udp_nostr.py pass):

Out of Scope

  • Constant-size padding to 1472 bytes (future optimization from §10 of the design doc)
  • Key distribution infrastructure (NIP-11, DNS advertisement)
  • Replay protection (inner event dedup handles this for fire-and-forget)