Files
udp_nostr/plans/encryption_implementation_plan.md
T
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

245 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`](../src/udp_nostr_send.py) | Reads JSON from stdin, sends as raw UDP | **Yes** — add encrypted send mode |
| [`src/udp_nostr_recv.py`](../src/udp_nostr_recv.py) | Receives UDP, prints decoded JSON | **Yes** — add decryption, handle both formats |
| [`src/udp_nostr_send.js`](../src/udp_nostr_send.js) | Builds demo event, sends as JSON | **Yes** — add encrypted send mode |
| [`src/udp_nostr_recv.js`](../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:**
```python
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:**
```bash
# 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:**
```bash
# 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:
```bash
#!/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:**
```bash
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 || base64_decode(nak encrypt ...)` decrypts successfully |
| 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`](../src/nip44.py) | NIP-44 v2 encrypt/decrypt in Python |
| [`src/udp_encrypt_send.sh`](../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`](../src/udp_nostr_send.py) | Add `--relay-pubkey` arg. If provided, import nip44, encrypt before send. |
| [`src/udp_nostr_recv.py`](../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`](../src/udp_nostr_send.js) | Add `--relay-pubkey` arg, NIP-44 encryption before send. |
| [`src/udp_nostr_recv.js`](../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):
- [`src/nip44.py`](../src/nip44.py) / [`src/nip44.js`](../src/nip44.js) — shared NIP-44 v2 crypto helpers, byte-for-byte interoperable with `nak` v0.19.4 in both directions
- [`src/udp_nostr_send.py`](../src/udp_nostr_send.py) / [`src/udp_nostr_recv.py`](../src/udp_nostr_recv.py) — upgraded in place; plaintext default, `--relay-pubkey` / `--relay-secret` (or environment variables) for encrypted mode
- [`src/udp_nostr_send.js`](../src/udp_nostr_send.js) / [`src/udp_nostr_recv.js`](../src/udp_nostr_recv.js) — upgraded in place with the same flags
- [`src/udp_encrypt_send.sh`](../src/udp_encrypt_send.sh) — `nak` + `nc` shell wrapper
- [`src/test_udp_nostr.py`](../src/test_udp_nostr.py) — plaintext, encrypted round trip, mixed traffic, leading-`{` collision fallback, malformed framing, tampering, wrong key, `nak` interop (skips clearly when `nak` is 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)