- 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
12 KiB
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
nakv0.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
- conversation key = HKDF-Extract(salt=
nakcompatibility: bytes 32 onward are exactlybase64_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
0x02version 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:
- Add
--relay-secretargument (hex-encoded private key, optional). - 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-secretwas supplied, encrypted datagrams cannot be processed and are dropped.
- If it starts with
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:
- Add
--relay-pubkeyargument (hex-encoded relay public key, optional). - If
--relay-pubkeyis provided:- Read signed event JSON from stdin.
- Generate a fresh ephemeral keypair; programmatic implementations may regenerate when the x-only pubkey begins with
0x7bas 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.
- If
--relay-pubkeyis 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-pubkeyargument. - Use
@noble/secp256k1for ECDH and Node.jscryptofor HKDF, ChaCha20, and HMAC. - Emit the same
ephem_pub(32) || native_nip44_payloadframing as thenakcommand-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-secretargument. - 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
0x02at byte 32. - Decrypt and authenticate the native NIP-44 payload in bytes 32..end when
--relay-secretis 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;coincurveturned out to be unnecessary)
JavaScript
- None — Node.js built-in
cryptomodule 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):
src/nip44.py/src/nip44.js— shared NIP-44 v2 crypto helpers, byte-for-byte interoperable withnakv0.19.4 in both directionssrc/udp_nostr_send.py/src/udp_nostr_recv.py— upgraded in place; plaintext default,--relay-pubkey/--relay-secret(or environment variables) for encrypted modesrc/udp_nostr_send.js/src/udp_nostr_recv.js— upgraded in place with the same flagssrc/udp_encrypt_send.sh—nak+ncshell wrappersrc/test_udp_nostr.py— plaintext, encrypted round trip, mixed traffic, leading-{collision fallback, malformed framing, tampering, wrong key,nakinterop (skips clearly whennakis absent), oversized datagram rejection
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)