- 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
245 lines
12 KiB
Markdown
245 lines
12 KiB
Markdown
# 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)
|