Files
udp_nostr/src/udp_nostr_recv.py
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

139 lines
5.3 KiB
Python

#!/usr/bin/env python3
"""UDP Nostr Receiver — listens for Nostr events as single UDP datagrams.
Usage:
# Plaintext mode (unchanged):
python3 udp_nostr_recv.py [bind_host] [port]
# Encrypted mode (also accepts plaintext on the same port):
python3 udp_nostr_recv.py [bind_host] [port] --relay-secret <relay_sec_hex>
# The relay secret may also be supplied via the UDP_NOSTR_RELAY_SECRET
# environment variable (the flag takes precedence).
Both plaintext and encrypted events are accepted on the same port. Detection:
1. If the datagram starts with '{', attempt strict plaintext JSON/event
validation (JSON object with the required Nostr event fields).
2. If that fails (or the datagram does not start with '{'), fall back to
encrypted framing detection: length >= 131, bytes 0..31 a valid x-only
secp256k1 pubkey, byte 32 == NIP-44 version 0x02. This handles the
leading-'{' ephemeral-pubkey collision.
Encrypted datagrams are decrypted with the relay secret (NIP-44 v2, wire
format: ephemeral_xonly_pubkey[32] || native payload). Any framing, key,
version, padding, or MAC failure drops the datagram silently. Without
--relay-secret, encrypted datagrams cannot be processed and are dropped.
"""
import argparse
import json
import os
import socket
import sys
try:
import nip44
except ImportError: # running from another directory
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import nip44
MAX_SINGLE_PACKET = 1472 # Ethernet MTU budget for one unfragmented datagram
EVENT_REQUIRED_FIELDS = ("id", "pubkey", "created_at", "kind", "tags", "content", "sig")
def parse_args(argv):
parser = argparse.ArgumentParser(
description="Receive Nostr events as single UDP datagrams (plaintext and/or encrypted)."
)
parser.add_argument("bind_host", nargs="?", default="0.0.0.0", help="bind host (default: 0.0.0.0)")
parser.add_argument("port", nargs="?", type=int, default=8888, help="bind port (default: 8888)")
parser.add_argument("--relay-secret", default=None,
help="relay secret key (64 hex chars) enabling NIP-44 v2 decryption; "
"also readable from UDP_NOSTR_RELAY_SECRET")
return parser.parse_args(argv)
def parse_plaintext_event(data: bytes):
"""Strictly validate a plaintext Nostr event datagram.
Returns the parsed event dict, or None if data is not a valid plaintext
event (bad JSON, not an object, or missing required fields).
"""
try:
event = json.loads(data.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
return None
if not isinstance(event, dict):
return None
if any(field not in event for field in EVENT_REQUIRED_FIELDS):
return None
return event
def _log(message: str):
print(message, flush=True)
def handle_datagram(data: bytes, addr, relay_secret=None, log=_log):
"""Process one datagram; returns 'plaintext', 'encrypted', or None (dropped)."""
if len(data) > MAX_SINGLE_PACKET:
log(f"⚠ {len(data)} bytes from {addr[0]}:{addr[1]} exceeds the "
f"{MAX_SINGLE_PACKET}-byte single-datagram budget (fragmented on path)")
# Stage 1: strict plaintext validation (only when it plausibly is JSON).
if data[:1] == b"{":
event = parse_plaintext_event(data)
if event is not None:
log(f"{len(data)} bytes from {addr[0]}:{addr[1]} (plaintext)")
log(json.dumps(event, separators=(",", ":")))
return "plaintext"
# Fall through: a leading '{' can also be the first byte of an
# ephemeral pubkey — try encrypted framing below.
# Stage 2: encrypted framing detection and decryption.
if nip44.looks_encrypted(data):
if relay_secret is None:
return None # encrypted traffic not enabled; drop silently
try:
plaintext = nip44.decrypt(data, relay_secret)
except nip44.NIP44Error:
return None # wrong key, tampering, or malformed framing; drop silently
event = parse_plaintext_event(plaintext)
if event is None:
return None # decrypted bytes are not a valid event; drop silently
log(f"{len(data)} bytes from {addr[0]}:{addr[1]} (encrypted, "
f"{len(plaintext)}-byte inner event)")
log(json.dumps(event, separators=(",", ":")))
return "encrypted"
return None # neither valid plaintext nor plausible encrypted framing
def main(argv=None):
args = parse_args(argv)
relay_secret = args.relay_secret or os.environ.get("UDP_NOSTR_RELAY_SECRET")
if relay_secret:
try:
relay_secret = nip44.validate_secret(relay_secret)
except nip44.NIP44Error as e:
print(f"error: invalid --relay-secret: {e}", file=sys.stderr)
return 2
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind((args.bind_host, args.port))
mode = "plaintext + encrypted" if relay_secret else "plaintext only"
print(f"Listening for Nostr events on UDP {args.bind_host}:{args.port} ({mode})",
flush=True)
try:
while True:
data, addr = sock.recvfrom(65535)
handle_datagram(data, addr, relay_secret)
except KeyboardInterrupt:
return 0
if __name__ == "__main__":
sys.exit(main())